seed(RoleSeeder::class); } // --- Rate Limiting --- public function test_login_rate_limited_after_5_attempts(): void { $user = User::factory()->create(); for ($i = 0; $i < 5; $i++) { $this->postJson('/api/v1/auth/login', [ 'email' => $user->email, 'password' => 'WrongPassword1', ]); } $response = $this->postJson('/api/v1/auth/login', [ 'email' => $user->email, 'password' => 'WrongPassword1', ]); $response->assertStatus(429); } public function test_portal_token_auth_rate_limited(): void { for ($i = 0; $i < 10; $i++) { $this->postJson('/api/v1/portal/token-auth', [ 'token' => 'invalid-token-' . $i, ]); } $response = $this->postJson('/api/v1/portal/token-auth', [ 'token' => 'invalid-token-final', ]); $response->assertStatus(429); } public function test_invitation_show_rate_limited(): void { for ($i = 0; $i < 10; $i++) { $this->getJson('/api/v1/invitations/fake-token-' . $i); } $response = $this->getJson('/api/v1/invitations/fake-token-final'); $response->assertStatus(429); } // --- Account Enumeration Prevention --- public function test_failed_login_returns_generic_message(): void { $response = $this->postJson('/api/v1/auth/login', [ 'email' => 'nonexistent@example.com', 'password' => 'SomePassword1', ]); $response->assertUnauthorized(); // Should NOT say "User not found" or "Email does not exist" $response->assertJson(['message' => 'Invalid credentials']); } public function test_failed_login_same_message_for_wrong_password(): void { $user = User::factory()->create(); $response = $this->postJson('/api/v1/auth/login', [ 'email' => $user->email, 'password' => 'WrongPassword1', ]); $response->assertUnauthorized(); $response->assertJson(['message' => 'Invalid credentials']); } public function test_password_reset_returns_success_for_unknown_email(): void { $response = $this->postJson('/api/v1/auth/forgot-password', [ 'email' => 'nonexistent@example.com', 'app' => 'app', ]); // Must return 200 regardless — don't leak whether email exists $response->assertOk(); } public function test_password_reset_returns_success_for_known_email(): void { $user = User::factory()->create(); $response = $this->postJson('/api/v1/auth/forgot-password', [ 'email' => $user->email, 'app' => 'app', ]); $response->assertOk(); } // --- Token Lifecycle --- public function test_logout_revokes_current_token(): void { $user = User::factory()->create(); $token = $user->createToken('auth-token')->plainTextToken; // Use token for logout $this->withHeaders(['Authorization' => "Bearer {$token}"]) ->postJson('/api/v1/auth/logout') ->assertOk(); // Verify the token record is deleted from the database $this->assertDatabaseCount('personal_access_tokens', 0); } public function test_password_reset_revokes_all_tokens(): void { $user = User::factory()->create(['email' => 'tokens@test.nl']); // Create two tokens $user->createToken('device-1'); $user->createToken('device-2'); $this->assertDatabaseCount('personal_access_tokens', 2); // Reset password $resetToken = Password::createToken($user); $this->postJson('/api/v1/auth/reset-password', [ 'token' => $resetToken, 'email' => 'tokens@test.nl', 'password' => 'NewPassword123', 'password_confirmation' => 'NewPassword123', ])->assertOk(); // All tokens should be deleted from the database $this->assertDatabaseCount('personal_access_tokens', 0); } public function test_token_expiration_is_configured(): void { // Verify Sanctum expiration is set to 7 days (10080 minutes) $expiration = config('sanctum.expiration'); $this->assertEquals(60 * 24 * 7, $expiration); // Create a token and backdate its created_at to 8 days ago $user = User::factory()->create(); $accessToken = $user->createToken('auth-token'); // Sanctum checks created_at against the expiration window $accessToken->accessToken->forceFill([ 'created_at' => now()->subDays(8), ])->save(); // Token created 8 days ago should be expired (7-day expiry) $this->withHeaders(['Authorization' => "Bearer {$accessToken->plainTextToken}"]) ->getJson('/api/v1/auth/me') ->assertUnauthorized(); } // --- Password Strength --- public function test_weak_password_rejected_on_registration(): void { $response = $this->postJson('/api/v1/auth/reset-password', [ 'token' => 'fake', 'email' => 'test@test.nl', 'password' => 'weakpass', 'password_confirmation' => 'weakpass', ]); $response->assertUnprocessable(); $response->assertJsonValidationErrors('password'); } public function test_password_without_uppercase_rejected(): void { $response = $this->postJson('/api/v1/auth/reset-password', [ 'token' => 'fake', 'email' => 'test@test.nl', 'password' => 'lowercase1', 'password_confirmation' => 'lowercase1', ]); $response->assertUnprocessable(); $response->assertJsonValidationErrors('password'); } public function test_password_without_numbers_rejected(): void { $response = $this->postJson('/api/v1/auth/reset-password', [ 'token' => 'fake', 'email' => 'test@test.nl', 'password' => 'NoNumbersHere', 'password_confirmation' => 'NoNumbersHere', ]); $response->assertUnprocessable(); $response->assertJsonValidationErrors('password'); } // --- Security Headers --- public function test_security_headers_present(): void { $response = $this->getJson('/api/v1/'); $response->assertHeader('X-Content-Type-Options', 'nosniff'); $response->assertHeader('X-Frame-Options', 'DENY'); $response->assertHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); $response->assertHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); } // --- Protected Routes --- public function test_protected_routes_require_authentication(): void { $this->getJson('/api/v1/auth/me')->assertUnauthorized(); $this->postJson('/api/v1/auth/logout')->assertUnauthorized(); $this->getJson('/api/v1/organisations')->assertUnauthorized(); $this->getJson('/api/v1/portal/me?event_id=fake')->assertUnauthorized(); } }