6 Commits

Author SHA1 Message Date
Jean-Marc Strauven
5250000618 chore(release): 2.2.0 2026-01-29 04:21:31 +01:00
Jean-Marc Strauven
b62ec456ad chore(release): 2.1.0 2026-01-29 04:20:44 +01:00
Jean-Marc Strauven
03de471bb5 Merge pull request #17 from Grazulex/docs/readme-v2.2.0
docs: add documentation for v2.2.0 features
2026-01-29 04:19:59 +01:00
Jean-Marc Strauven
b14f4f2dd9 docs: add documentation for email verification, password reset and middleware
- Document email verification flow with curl examples
- Document password reset flow with curl examples
- Document 3 middleware patterns (force.json, log.api, verified)
- Update Features section with new capabilities
- Update register response example with email verification message
2026-01-29 04:19:28 +01:00
ELMEHDI ACHAHED
13da9b84c1 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)
2026-01-29 04:16:33 +01:00
Jean-Marc Strauven
07c36c0009 chore(release): 2.1.0 (#14) 2026-01-20 21:01:45 +01:00
16 changed files with 778 additions and 14 deletions

View File

@@ -2,11 +2,27 @@
All notable changes to this project will be documented in this file.
## [2.1.0](https://github.com/Grazulex/laravel-api-kit/releases/tag/v2.1.0) (2026-01-20)
## [2.2.0](https://github.com/Grazulex/laravel-api-kit/releases/tag/v2.2.0) (2026-01-29)
### Features
- integrate PHPStan, Rector and Pint code quality tools (#13) ([f417ec2](https://github.com/Grazulex/laravel-api-kit/commit/f417ec23c2eada4eaddf3ec9e4cff7ccc71a3572))
- add email verification endpoints with signed URLs (#16)
- add password reset flow with secure tokens (#16)
- add 3 reusable API middleware: ForceJsonResponse, LogApiRequests, EnsureEmailVerified (#16)
### Documentation
- add documentation for email verification, password reset and middleware (#17)
## [2.1.0](https://github.com/Grazulex/laravel-api-kit/releases/tag/v2.1.0) (2026-01-29)
### Features
- integrate PHPStan, Rector and Pint code quality tools (#13)
### Chores
- **release:** 2.1.0 (#14)
## [2.0.2](https://github.com/Grazulex/laravel-api-kit/releases/tag/v2.0.2) (2026-01-08)
### Bug Fixes

151
README.md
View File

@@ -10,6 +10,8 @@ A production-ready, API-only Laravel 12 starter kit following the 2024-2025 REST
- **API-Only** - No Blade, Vite, or frontend assets
- **Token Authentication** - Laravel Sanctum for mobile/SPA auth
- **Email Verification** - Built-in email verification flow with signed URLs
- **Password Reset** - Secure password reset with token-based flow
- **API Versioning** - URI-based versioning with deprecation support via [grazulex/laravel-apiroute](https://github.com/Grazulex/laravel-apiroute)
- **Query Building** - Filtering, sorting, includes via [spatie/laravel-query-builder](https://github.com/spatie/laravel-query-builder)
- **Data Objects** - Type-safe DTOs via [spatie/laravel-data](https://github.com/spatie/laravel-data)
@@ -17,6 +19,7 @@ A production-ready, API-only Laravel 12 starter kit following the 2024-2025 REST
- **Modern Testing** - Pest PHP with Laravel HTTP testing
- **Code Quality** - PHPStan (max level), Rector, and Pint with strict rules
- **Rate Limiting** - Configurable per-route rate limiters
- **Reusable Middleware** - ForceJsonResponse, LogApiRequests, EnsureEmailVerified
- **Standardized Responses** - Consistent JSON response format
## Requirements
@@ -102,12 +105,13 @@ curl -X POST http://localhost:8080/api/v1/register \
```json
{
"success": true,
"message": "User registered successfully",
"message": "User registered successfully. Please check your email to verify your account.",
"data": {
"user": {
"id": 1,
"name": "John Doe",
"email": "john@example.com",
"email_verified_at": null,
"created_at": "2025-01-15T10:30:00+00:00"
},
"token": "1|abc123..."
@@ -145,16 +149,92 @@ curl -X POST http://localhost:8080/api/v1/logout \
-H "Accept: application/json"
```
### Email Verification
After registration, users receive a verification email. The kit integrates with Laravel's `MustVerifyEmail` contract.
**Verify Email (via signed URL from email):**
```bash
curl -X POST "http://localhost:8080/api/v1/email/verify/{id}/{hash}?signature=..." \
-H "Authorization: Bearer 1|abc123..." \
-H "Accept: application/json"
```
**Resend Verification Email:**
```bash
curl -X POST http://localhost:8080/api/v1/email/resend \
-H "Authorization: Bearer 1|abc123..." \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"email": "john@example.com"}'
```
**Response:**
```json
{
"success": true,
"message": "Verification email sent successfully",
"data": null
}
```
### Password Reset
**Request Password Reset Link:**
```bash
curl -X POST http://localhost:8080/api/v1/forgot-password \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"email": "john@example.com"}'
```
**Response:**
```json
{
"success": true,
"message": "Password reset link sent to your email",
"data": null
}
```
**Reset Password (with token from email):**
```bash
curl -X POST http://localhost:8080/api/v1/reset-password \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"email": "john@example.com",
"token": "reset-token-from-email",
"password": "newpassword123",
"password_confirmation": "newpassword123"
}'
```
**Response:**
```json
{
"success": true,
"message": "Password reset successfully",
"data": null
}
```
> **Note:** After a successful password reset, all user tokens are revoked for security.
## API Endpoints
### Version 1 (`/api/v1`)
| Method | Endpoint | Auth | Description | Rate Limit |
|--------|-------------|------|--------------------------|------------|
| POST | /register | No | Register new user | 5/min |
| POST | /login | No | Get authentication token | 5/min |
| POST | /logout | Yes | Revoke current token | 60/min |
| GET | /me | Yes | Get current user profile | 60/min |
| Method | Endpoint | Auth | Description | Rate Limit |
|--------|------------------------------|------|-------------------------------|------------|
| POST | /register | No | Register new user | 5/min |
| POST | /login | No | Get authentication token | 5/min |
| POST | /logout | Yes | Revoke current token | 120/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
@@ -385,6 +465,63 @@ X-RateLimit-Remaining: 59
Retry-After: 60 # When limit exceeded
```
## Middleware
The kit includes three production-ready middleware patterns that you can apply to your routes as needed.
### Available Middleware
| Alias | Class | Description |
|-------|-------|-------------|
| `force.json` | `ForceJsonResponse` | Ensures all responses are JSON formatted |
| `log.api` | `LogApiRequests` | Logs API requests with timing information |
| `verified` | `EnsureEmailVerified` | Requires verified email to access route |
### ForceJsonResponse
Automatically sets `Accept: application/json` header and converts non-JSON responses to JSON format.
```php
Route::middleware('force.json')->group(function () {
// All responses will be JSON
});
```
### LogApiRequests
Logs API requests with detailed information and adds `X-Response-Time` header to responses.
**Logged data:** timestamp, method, URL, IP, user ID, status code, duration (ms), user agent.
**Enable logging via environment:**
```env
APP_LOG_API_REQUESTS=true
```
```php
Route::middleware('log.api')->group(function () {
// Requests will be logged
});
```
### EnsureEmailVerified
Protects routes that require a verified email address. Returns 403 if email is not verified.
```php
Route::middleware(['auth:sanctum', 'verified'])->group(function () {
// Only users with verified emails can access
});
```
**Response when email not verified:**
```json
{
"success": false,
"message": "Your email address is not verified. Please verify your email to continue."
}
```
## Testing
This kit uses [Pest PHP](https://pestphp.com/) for testing:

View File

@@ -5,13 +5,20 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
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\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\Models\User;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Auth\Events\Verified;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
final class AuthController extends ApiController
{
@@ -23,12 +30,14 @@ final class AuthController extends ApiController
'password' => Hash::make($request->password),
]);
$user->sendEmailVerificationNotification();
$token = $user->createToken('auth-token')->plainTextToken;
return $this->created([
'user' => new UserResource($user),
'token' => $token,
], 'User registered successfully');
], 'User registered successfully. Please check your email to verify your account.');
}
public function login(LoginRequest $request): JsonResponse
@@ -60,4 +69,79 @@ final class AuthController extends ApiController
{
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
);
}
}

View 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);
}
}

View 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;
}
}

View 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;
}
}

View 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'],
];
}
}

View 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'],
];
}
}

View 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()],
];
}
}

View 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 [];
}
}

View File

@@ -4,8 +4,8 @@ declare(strict_types=1);
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
@@ -21,7 +21,7 @@ use Laravel\Sanctum\HasApiTokens;
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
*/
final class User extends Authenticatable
final class User extends Authenticatable implements MustVerifyEmail
{
use HasApiTokens;

View File

@@ -2,6 +2,9 @@
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\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
@@ -14,7 +17,11 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
//
$middleware->alias([
'force.json' => ForceJsonResponse::class,
'log.api' => LogApiRequests::class,
'verified' => EnsureEmailVerified::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
//

View File

@@ -24,4 +24,20 @@ Route::middleware('throttle:auth')->group(function (): void {
Route::middleware(['auth:sanctum', 'throttle:authenticated'])->group(function (): void {
Route::post('logout', [AuthController::class, 'logout'])->name('api.v1.logout');
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');
});

View File

@@ -27,7 +27,7 @@ describe('Registration', function (): void {
])
->assertJson([
'success' => true,
'message' => 'User registered successfully',
'message' => 'User registered successfully. Please check your email to verify your account.',
]);
$this->assertDatabaseHas('users', [

View 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);
});
});

View 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',
]);
});
});