security: add rate limiting to admin action endpoints (#12)
Add per-user rate limiting to sensitive Livewire component methods to prevent abuse from compromised admin sessions. Introduces a reusable HasRateLimiting trait and applies it to PlatformUser, Settings, and WaitlistManager components. Rate limits: - Tier changes, verification, entitlements: 10/min per admin - Profile updates, preferences: 20/min per user - Password changes: 5/min per user - Data exports: 5/min per admin - Deletions/anonymisation: 3/min per admin Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6bdb6c0242
commit
9ae0055f33
5 changed files with 690 additions and 292 deletions
72
src/Website/Hub/Concerns/HasRateLimiting.php
Normal file
72
src/Website/Hub/Concerns/HasRateLimiting.php
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Website\Hub\Concerns;
|
||||
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
/**
|
||||
* Provides rate limiting helpers for Livewire admin components.
|
||||
*
|
||||
* Usage:
|
||||
* $this->rateLimit('tier-change', 10, function () { ... });
|
||||
* $this->rateLimit('waitlist-export', 5, function () { ... });
|
||||
*/
|
||||
trait HasRateLimiting
|
||||
{
|
||||
/**
|
||||
* Execute a callback with rate limiting for mutation actions.
|
||||
*
|
||||
* @param string $action Short action identifier (e.g. 'tier-change')
|
||||
* @param int $maxAttempts Maximum attempts per minute
|
||||
* @param callable $callback The action to execute if within limits
|
||||
* @param int $decaySeconds Rate limit window in seconds
|
||||
*/
|
||||
protected function rateLimit(string $action, int $maxAttempts, callable $callback, int $decaySeconds = 60): mixed
|
||||
{
|
||||
$key = $this->rateLimitKey($action);
|
||||
|
||||
$executed = false;
|
||||
$result = null;
|
||||
|
||||
RateLimiter::attempt(
|
||||
$key,
|
||||
$maxAttempts,
|
||||
function () use ($callback, &$executed, &$result) {
|
||||
$executed = true;
|
||||
$result = $callback();
|
||||
},
|
||||
$decaySeconds,
|
||||
);
|
||||
|
||||
if (! $executed) {
|
||||
$this->onRateLimited($action, $key);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a rate limit key scoped to the authenticated user.
|
||||
*/
|
||||
protected function rateLimitKey(string $action): string
|
||||
{
|
||||
return $action.':'.auth()->id();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle rate limit exceeded - sets action message on the component.
|
||||
*/
|
||||
protected function onRateLimited(string $action, string $key): void
|
||||
{
|
||||
$seconds = RateLimiter::availableIn($key);
|
||||
|
||||
if (property_exists($this, 'actionMessage') && property_exists($this, 'actionType')) {
|
||||
$this->actionMessage = "Too many requests. Please wait {$seconds} seconds before trying again.";
|
||||
$this->actionType = 'error';
|
||||
} else {
|
||||
session()->flash('error', "Too many requests. Please wait {$seconds} seconds before trying again.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -14,9 +14,12 @@ use Core\Tenant\Models\Package;
|
|||
use Core\Tenant\Models\User;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Core\Tenant\Services\EntitlementService;
|
||||
use Website\Hub\Concerns\HasRateLimiting;
|
||||
|
||||
class PlatformUser extends Component
|
||||
{
|
||||
use HasRateLimiting;
|
||||
|
||||
public User $user;
|
||||
|
||||
// Editable fields
|
||||
|
|
@ -80,27 +83,31 @@ class PlatformUser extends Component
|
|||
|
||||
public function saveTier(): void
|
||||
{
|
||||
$this->user->tier = UserTier::from($this->editingTier);
|
||||
$this->user->save();
|
||||
$this->rateLimit('admin-tier-change', 10, function () {
|
||||
$this->user->tier = UserTier::from($this->editingTier);
|
||||
$this->user->save();
|
||||
|
||||
$this->actionMessage = "Tier updated to {$this->editingTier}.";
|
||||
$this->actionType = 'success';
|
||||
$this->actionMessage = "Tier updated to {$this->editingTier}.";
|
||||
$this->actionType = 'success';
|
||||
});
|
||||
}
|
||||
|
||||
public function saveVerification(): void
|
||||
{
|
||||
if ($this->editingVerified && ! $this->user->email_verified_at) {
|
||||
$this->user->email_verified_at = now();
|
||||
} elseif (! $this->editingVerified) {
|
||||
$this->user->email_verified_at = null;
|
||||
}
|
||||
$this->rateLimit('admin-verification', 10, function () {
|
||||
if ($this->editingVerified && ! $this->user->email_verified_at) {
|
||||
$this->user->email_verified_at = now();
|
||||
} elseif (! $this->editingVerified) {
|
||||
$this->user->email_verified_at = null;
|
||||
}
|
||||
|
||||
$this->user->save();
|
||||
$this->user->save();
|
||||
|
||||
$this->actionMessage = $this->editingVerified
|
||||
? 'Email marked as verified.'
|
||||
: 'Email verification removed.';
|
||||
$this->actionType = 'success';
|
||||
$this->actionMessage = $this->editingVerified
|
||||
? 'Email marked as verified.'
|
||||
: 'Email verification removed.';
|
||||
$this->actionType = 'success';
|
||||
});
|
||||
}
|
||||
|
||||
public function resendVerification(): void
|
||||
|
|
@ -123,21 +130,23 @@ class PlatformUser extends Component
|
|||
*/
|
||||
public function exportUserData()
|
||||
{
|
||||
$data = $this->collectUserData();
|
||||
return $this->rateLimit('admin-export', 5, function () {
|
||||
$data = $this->collectUserData();
|
||||
|
||||
$filename = "user-data-{$this->user->id}-".now()->format('Y-m-d-His').'.json';
|
||||
$filename = "user-data-{$this->user->id}-".now()->format('Y-m-d-His').'.json';
|
||||
|
||||
Log::info('GDPR data export performed by admin', [
|
||||
'admin_id' => auth()->id(),
|
||||
'target_user_id' => $this->user->id,
|
||||
'target_email' => $this->user->email,
|
||||
]);
|
||||
Log::info('GDPR data export performed by admin', [
|
||||
'admin_id' => auth()->id(),
|
||||
'target_user_id' => $this->user->id,
|
||||
'target_email' => $this->user->email,
|
||||
]);
|
||||
|
||||
return response()->streamDownload(function () use ($data) {
|
||||
echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
}, $filename, [
|
||||
'Content-Type' => 'application/json',
|
||||
]);
|
||||
return response()->streamDownload(function () use ($data) {
|
||||
echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
}, $filename, [
|
||||
'Content-Type' => 'application/json',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -240,32 +249,34 @@ class PlatformUser extends Component
|
|||
*/
|
||||
public function scheduleDelete(): void
|
||||
{
|
||||
if ($this->user->isHades() && $this->user->id === auth()->id()) {
|
||||
$this->actionMessage = 'You cannot delete your own Hades account from here.';
|
||||
$this->actionType = 'error';
|
||||
$this->rateLimit('admin-deletion', 3, function () {
|
||||
if ($this->user->isHades() && $this->user->id === auth()->id()) {
|
||||
$this->actionMessage = 'You cannot delete your own Hades account from here.';
|
||||
$this->actionType = 'error';
|
||||
$this->showDeleteConfirm = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$request = AccountDeletionRequest::createForUser($this->user, $this->deleteReason ?: 'Admin initiated - GDPR request');
|
||||
|
||||
Log::warning('GDPR deletion scheduled by admin', [
|
||||
'admin_id' => auth()->id(),
|
||||
'target_user_id' => $this->user->id,
|
||||
'target_email' => $this->user->email,
|
||||
'immediate' => $this->immediateDelete,
|
||||
'reason' => $this->deleteReason,
|
||||
]);
|
||||
|
||||
if ($this->immediateDelete) {
|
||||
$this->executeImmediateDelete($request);
|
||||
} else {
|
||||
$this->actionMessage = 'Account deletion scheduled. Will be deleted in 7 days unless cancelled.';
|
||||
$this->actionType = 'warning';
|
||||
}
|
||||
|
||||
$this->showDeleteConfirm = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$request = AccountDeletionRequest::createForUser($this->user, $this->deleteReason ?: 'Admin initiated - GDPR request');
|
||||
|
||||
Log::warning('GDPR deletion scheduled by admin', [
|
||||
'admin_id' => auth()->id(),
|
||||
'target_user_id' => $this->user->id,
|
||||
'target_email' => $this->user->email,
|
||||
'immediate' => $this->immediateDelete,
|
||||
'reason' => $this->deleteReason,
|
||||
]);
|
||||
|
||||
if ($this->immediateDelete) {
|
||||
$this->executeImmediateDelete($request);
|
||||
} else {
|
||||
$this->actionMessage = 'Account deletion scheduled. Will be deleted in 7 days unless cancelled.';
|
||||
$this->actionType = 'warning';
|
||||
}
|
||||
|
||||
$this->showDeleteConfirm = false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -340,50 +351,52 @@ class PlatformUser extends Component
|
|||
*/
|
||||
public function anonymizeUser(): void
|
||||
{
|
||||
if ($this->user->isHades() && $this->user->id === auth()->id()) {
|
||||
$this->actionMessage = 'You cannot anonymize your own account.';
|
||||
$this->actionType = 'error';
|
||||
$this->rateLimit('admin-deletion', 3, function () {
|
||||
if ($this->user->isHades() && $this->user->id === auth()->id()) {
|
||||
$this->actionMessage = 'You cannot anonymize your own account.';
|
||||
$this->actionType = 'error';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$originalEmail = $this->user->email;
|
||||
$anonymizedId = 'anon_'.$this->user->id.'_'.now()->timestamp;
|
||||
|
||||
DB::transaction(function () use ($anonymizedId) {
|
||||
$this->user->update([
|
||||
'name' => 'Anonymized User',
|
||||
'email' => $anonymizedId.'@anonymized.local',
|
||||
'password' => bcrypt(str()->random(64)),
|
||||
'tier' => UserTier::FREE,
|
||||
'email_verified_at' => null,
|
||||
'cached_stats' => null,
|
||||
]);
|
||||
|
||||
// Remove from all workspaces
|
||||
if (method_exists($this->user, 'hostWorkspaces')) {
|
||||
$this->user->hostWorkspaces()->detach();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel any pending deletions
|
||||
AccountDeletionRequest::where('user_id', $this->user->id)
|
||||
->whereNull('completed_at')
|
||||
->whereNull('cancelled_at')
|
||||
->update(['cancelled_at' => now()]);
|
||||
$originalEmail = $this->user->email;
|
||||
$anonymizedId = 'anon_'.$this->user->id.'_'.now()->timestamp;
|
||||
|
||||
DB::transaction(function () use ($anonymizedId) {
|
||||
$this->user->update([
|
||||
'name' => 'Anonymized User',
|
||||
'email' => $anonymizedId.'@anonymized.local',
|
||||
'password' => bcrypt(str()->random(64)),
|
||||
'tier' => UserTier::FREE,
|
||||
'email_verified_at' => null,
|
||||
'cached_stats' => null,
|
||||
]);
|
||||
|
||||
// Remove from all workspaces
|
||||
if (method_exists($this->user, 'hostWorkspaces')) {
|
||||
$this->user->hostWorkspaces()->detach();
|
||||
}
|
||||
|
||||
// Cancel any pending deletions
|
||||
AccountDeletionRequest::where('user_id', $this->user->id)
|
||||
->whereNull('completed_at')
|
||||
->whereNull('cancelled_at')
|
||||
->update(['cancelled_at' => now()]);
|
||||
});
|
||||
|
||||
Log::warning('User anonymized by admin (GDPR)', [
|
||||
'admin_id' => auth()->id(),
|
||||
'target_user_id' => $this->user->id,
|
||||
'original_email' => $originalEmail,
|
||||
]);
|
||||
|
||||
$this->user->refresh();
|
||||
$this->editingTier = $this->user->tier?->value ?? 'free';
|
||||
$this->editingVerified = false;
|
||||
|
||||
$this->actionMessage = 'User data has been anonymized.';
|
||||
$this->actionType = 'success';
|
||||
});
|
||||
|
||||
Log::warning('User anonymized by admin (GDPR)', [
|
||||
'admin_id' => auth()->id(),
|
||||
'target_user_id' => $this->user->id,
|
||||
'original_email' => $originalEmail,
|
||||
]);
|
||||
|
||||
$this->user->refresh();
|
||||
$this->editingTier = $this->user->tier?->value ?? 'free';
|
||||
$this->editingVerified = false;
|
||||
|
||||
$this->actionMessage = 'User data has been anonymized.';
|
||||
$this->actionType = 'success';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -448,33 +461,35 @@ class PlatformUser extends Component
|
|||
*/
|
||||
public function provisionPackage(): void
|
||||
{
|
||||
if (! $this->selectedWorkspaceId || ! $this->selectedPackageCode) {
|
||||
$this->actionMessage = 'Please select a workspace and package.';
|
||||
$this->actionType = 'warning';
|
||||
$this->rateLimit('admin-entitlement', 10, function () {
|
||||
if (! $this->selectedWorkspaceId || ! $this->selectedPackageCode) {
|
||||
$this->actionMessage = 'Please select a workspace and package.';
|
||||
$this->actionType = 'warning';
|
||||
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$workspace = Workspace::findOrFail($this->selectedWorkspaceId);
|
||||
$package = Package::where('code', $this->selectedPackageCode)->firstOrFail();
|
||||
$workspace = Workspace::findOrFail($this->selectedWorkspaceId);
|
||||
$package = Package::where('code', $this->selectedPackageCode)->firstOrFail();
|
||||
|
||||
$entitlements = app(EntitlementService::class);
|
||||
$entitlements->provisionPackage($workspace, $this->selectedPackageCode, [
|
||||
'source' => 'admin',
|
||||
]);
|
||||
$entitlements = app(EntitlementService::class);
|
||||
$entitlements->provisionPackage($workspace, $this->selectedPackageCode, [
|
||||
'source' => 'admin',
|
||||
]);
|
||||
|
||||
Log::info('Package provisioned by admin', [
|
||||
'admin_id' => auth()->id(),
|
||||
'user_id' => $this->user->id,
|
||||
'workspace_id' => $workspace->id,
|
||||
'package_code' => $this->selectedPackageCode,
|
||||
]);
|
||||
Log::info('Package provisioned by admin', [
|
||||
'admin_id' => auth()->id(),
|
||||
'user_id' => $this->user->id,
|
||||
'workspace_id' => $workspace->id,
|
||||
'package_code' => $this->selectedPackageCode,
|
||||
]);
|
||||
|
||||
$this->actionMessage = "Package '{$package->name}' provisioned to workspace '{$workspace->name}'.";
|
||||
$this->actionType = 'success';
|
||||
$this->actionMessage = "Package '{$package->name}' provisioned to workspace '{$workspace->name}'.";
|
||||
$this->actionType = 'success';
|
||||
|
||||
$this->closePackageModal();
|
||||
unset($this->workspaces); // Clear computed cache
|
||||
$this->closePackageModal();
|
||||
unset($this->workspaces); // Clear computed cache
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -482,34 +497,36 @@ class PlatformUser extends Component
|
|||
*/
|
||||
public function revokePackage(int $workspaceId, string $packageCode): void
|
||||
{
|
||||
$workspace = Workspace::findOrFail($workspaceId);
|
||||
$this->rateLimit('admin-entitlement', 10, function () use ($workspaceId, $packageCode) {
|
||||
$workspace = Workspace::findOrFail($workspaceId);
|
||||
|
||||
// Verify this belongs to one of the user's workspaces
|
||||
if (! $this->user->hostWorkspaces->contains($workspace)) {
|
||||
$this->actionMessage = 'This workspace does not belong to this user.';
|
||||
$this->actionType = 'error';
|
||||
// Verify this belongs to one of the user's workspaces
|
||||
if (! $this->user->hostWorkspaces->contains($workspace)) {
|
||||
$this->actionMessage = 'This workspace does not belong to this user.';
|
||||
$this->actionType = 'error';
|
||||
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$package = Package::where('code', $packageCode)->first();
|
||||
$packageName = $package?->name ?? $packageCode;
|
||||
$workspaceName = $workspace->name;
|
||||
$package = Package::where('code', $packageCode)->first();
|
||||
$packageName = $package?->name ?? $packageCode;
|
||||
$workspaceName = $workspace->name;
|
||||
|
||||
$entitlements = app(EntitlementService::class);
|
||||
$entitlements->revokePackage($workspace, $packageCode, 'admin');
|
||||
$entitlements = app(EntitlementService::class);
|
||||
$entitlements->revokePackage($workspace, $packageCode, 'admin');
|
||||
|
||||
Log::info('Package revoked by admin', [
|
||||
'admin_id' => auth()->id(),
|
||||
'user_id' => $this->user->id,
|
||||
'workspace_id' => $workspace->id,
|
||||
'package_code' => $packageCode,
|
||||
]);
|
||||
Log::info('Package revoked by admin', [
|
||||
'admin_id' => auth()->id(),
|
||||
'user_id' => $this->user->id,
|
||||
'workspace_id' => $workspace->id,
|
||||
'package_code' => $packageCode,
|
||||
]);
|
||||
|
||||
$this->actionMessage = "Package '{$packageName}' revoked from workspace '{$workspaceName}'.";
|
||||
$this->actionType = 'success';
|
||||
$this->actionMessage = "Package '{$packageName}' revoked from workspace '{$workspaceName}'.";
|
||||
$this->actionType = 'success';
|
||||
|
||||
unset($this->workspaces); // Clear computed cache
|
||||
unset($this->workspaces); // Clear computed cache
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
|
@ -586,68 +603,70 @@ class PlatformUser extends Component
|
|||
*/
|
||||
public function provisionEntitlement(): void
|
||||
{
|
||||
if (! $this->entitlementWorkspaceId || ! $this->entitlementFeatureCode) {
|
||||
$this->actionMessage = 'Please select a workspace and feature.';
|
||||
$this->actionType = 'warning';
|
||||
$this->rateLimit('admin-entitlement', 10, function () {
|
||||
if (! $this->entitlementWorkspaceId || ! $this->entitlementFeatureCode) {
|
||||
$this->actionMessage = 'Please select a workspace and feature.';
|
||||
$this->actionType = 'warning';
|
||||
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$workspace = Workspace::findOrFail($this->entitlementWorkspaceId);
|
||||
$feature = Feature::where('code', $this->entitlementFeatureCode)->first();
|
||||
$workspace = Workspace::findOrFail($this->entitlementWorkspaceId);
|
||||
$feature = Feature::where('code', $this->entitlementFeatureCode)->first();
|
||||
|
||||
if (! $feature) {
|
||||
$this->actionMessage = 'Feature not found.';
|
||||
$this->actionType = 'error';
|
||||
if (! $feature) {
|
||||
$this->actionMessage = 'Feature not found.';
|
||||
$this->actionType = 'error';
|
||||
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify this belongs to one of the user's workspaces
|
||||
if (! $this->user->hostWorkspaces->contains($workspace)) {
|
||||
$this->actionMessage = 'This workspace does not belong to this user.';
|
||||
$this->actionType = 'error';
|
||||
// Verify this belongs to one of the user's workspaces
|
||||
if (! $this->user->hostWorkspaces->contains($workspace)) {
|
||||
$this->actionMessage = 'This workspace does not belong to this user.';
|
||||
$this->actionType = 'error';
|
||||
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$options = [
|
||||
'source' => 'admin',
|
||||
'boost_type' => match ($this->entitlementType) {
|
||||
'enable' => Boost::BOOST_TYPE_ENABLE,
|
||||
'add_limit' => Boost::BOOST_TYPE_ADD_LIMIT,
|
||||
'unlimited' => Boost::BOOST_TYPE_UNLIMITED,
|
||||
default => Boost::BOOST_TYPE_ENABLE,
|
||||
},
|
||||
'duration_type' => $this->entitlementDuration === 'permanent'
|
||||
? Boost::DURATION_PERMANENT
|
||||
: Boost::DURATION_DURATION,
|
||||
];
|
||||
$options = [
|
||||
'source' => 'admin',
|
||||
'boost_type' => match ($this->entitlementType) {
|
||||
'enable' => Boost::BOOST_TYPE_ENABLE,
|
||||
'add_limit' => Boost::BOOST_TYPE_ADD_LIMIT,
|
||||
'unlimited' => Boost::BOOST_TYPE_UNLIMITED,
|
||||
default => Boost::BOOST_TYPE_ENABLE,
|
||||
},
|
||||
'duration_type' => $this->entitlementDuration === 'permanent'
|
||||
? Boost::DURATION_PERMANENT
|
||||
: Boost::DURATION_DURATION,
|
||||
];
|
||||
|
||||
if ($this->entitlementType === 'add_limit' && $this->entitlementLimit) {
|
||||
$options['limit_value'] = $this->entitlementLimit;
|
||||
}
|
||||
if ($this->entitlementType === 'add_limit' && $this->entitlementLimit) {
|
||||
$options['limit_value'] = $this->entitlementLimit;
|
||||
}
|
||||
|
||||
if ($this->entitlementDuration === 'duration' && $this->entitlementExpiresAt) {
|
||||
$options['expires_at'] = $this->entitlementExpiresAt;
|
||||
}
|
||||
if ($this->entitlementDuration === 'duration' && $this->entitlementExpiresAt) {
|
||||
$options['expires_at'] = $this->entitlementExpiresAt;
|
||||
}
|
||||
|
||||
$entitlements = app(EntitlementService::class);
|
||||
$entitlements->provisionBoost($workspace, $this->entitlementFeatureCode, $options);
|
||||
$entitlements = app(EntitlementService::class);
|
||||
$entitlements->provisionBoost($workspace, $this->entitlementFeatureCode, $options);
|
||||
|
||||
Log::info('Entitlement provisioned by admin', [
|
||||
'admin_id' => auth()->id(),
|
||||
'user_id' => $this->user->id,
|
||||
'workspace_id' => $workspace->id,
|
||||
'feature_code' => $this->entitlementFeatureCode,
|
||||
'type' => $this->entitlementType,
|
||||
]);
|
||||
Log::info('Entitlement provisioned by admin', [
|
||||
'admin_id' => auth()->id(),
|
||||
'user_id' => $this->user->id,
|
||||
'workspace_id' => $workspace->id,
|
||||
'feature_code' => $this->entitlementFeatureCode,
|
||||
'type' => $this->entitlementType,
|
||||
]);
|
||||
|
||||
$this->actionMessage = "Entitlement '{$feature->name}' added to workspace '{$workspace->name}'.";
|
||||
$this->actionType = 'success';
|
||||
$this->actionMessage = "Entitlement '{$feature->name}' added to workspace '{$workspace->name}'.";
|
||||
$this->actionType = 'success';
|
||||
|
||||
$this->closeEntitlementModal();
|
||||
unset($this->workspaceEntitlements);
|
||||
$this->closeEntitlementModal();
|
||||
unset($this->workspaceEntitlements);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -655,34 +674,36 @@ class PlatformUser extends Component
|
|||
*/
|
||||
public function removeBoost(int $boostId): void
|
||||
{
|
||||
$boost = Boost::findOrFail($boostId);
|
||||
$this->rateLimit('admin-entitlement', 10, function () use ($boostId) {
|
||||
$boost = Boost::findOrFail($boostId);
|
||||
|
||||
// Verify this belongs to one of the user's workspaces
|
||||
$workspace = $boost->workspace;
|
||||
if (! $this->user->hostWorkspaces->contains($workspace)) {
|
||||
$this->actionMessage = 'This boost does not belong to this user.';
|
||||
$this->actionType = 'error';
|
||||
// Verify this belongs to one of the user's workspaces
|
||||
$workspace = $boost->workspace;
|
||||
if (! $this->user->hostWorkspaces->contains($workspace)) {
|
||||
$this->actionMessage = 'This boost does not belong to this user.';
|
||||
$this->actionType = 'error';
|
||||
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$featureCode = $boost->feature_code;
|
||||
$workspaceName = $workspace->name;
|
||||
$featureCode = $boost->feature_code;
|
||||
$workspaceName = $workspace->name;
|
||||
|
||||
$boost->update(['status' => Boost::STATUS_CANCELLED]);
|
||||
$boost->update(['status' => Boost::STATUS_CANCELLED]);
|
||||
|
||||
Log::info('Boost removed by admin', [
|
||||
'admin_id' => auth()->id(),
|
||||
'user_id' => $this->user->id,
|
||||
'workspace_id' => $workspace->id,
|
||||
'boost_id' => $boostId,
|
||||
'feature_code' => $featureCode,
|
||||
]);
|
||||
Log::info('Boost removed by admin', [
|
||||
'admin_id' => auth()->id(),
|
||||
'user_id' => $this->user->id,
|
||||
'workspace_id' => $workspace->id,
|
||||
'boost_id' => $boostId,
|
||||
'feature_code' => $featureCode,
|
||||
]);
|
||||
|
||||
$this->actionMessage = "Boost for '{$featureCode}' removed from workspace '{$workspaceName}'.";
|
||||
$this->actionType = 'success';
|
||||
$this->actionMessage = "Boost for '{$featureCode}' removed from workspace '{$workspaceName}'.";
|
||||
$this->actionType = 'success';
|
||||
|
||||
unset($this->workspaceEntitlements);
|
||||
unset($this->workspaceEntitlements);
|
||||
});
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -15,9 +15,12 @@ use Core\Tenant\Mail\AccountDeletionRequested;
|
|||
use Core\Tenant\Models\AccountDeletionRequest;
|
||||
use Core\Tenant\Models\User;
|
||||
use Core\Tenant\Services\UserStatsService;
|
||||
use Website\Hub\Concerns\HasRateLimiting;
|
||||
|
||||
class Settings extends Component
|
||||
{
|
||||
use HasRateLimiting;
|
||||
|
||||
// Active section for sidebar navigation
|
||||
#[Url(as: 'tab')]
|
||||
public string $activeSection = 'profile';
|
||||
|
|
@ -108,6 +111,12 @@ class Settings extends Component
|
|||
$this->timezones = UserStatsService::getTimezoneList();
|
||||
}
|
||||
|
||||
protected function onRateLimited(string $action, string $key): void
|
||||
{
|
||||
$seconds = \Illuminate\Support\Facades\RateLimiter::availableIn($key);
|
||||
Flux::toast(text: "Too many requests. Please wait {$seconds} seconds before trying again.", variant: 'danger');
|
||||
}
|
||||
|
||||
protected function getUserSetting(string $name, mixed $default = null): mixed
|
||||
{
|
||||
$setting = Setting::where('user_id', Auth::id())
|
||||
|
|
@ -119,19 +128,21 @@ class Settings extends Component
|
|||
|
||||
public function updateProfile(): void
|
||||
{
|
||||
$this->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255', 'unique:'.(new User)->getTable().',email,'.Auth::id()],
|
||||
]);
|
||||
$this->rateLimit('profile-update', 20, function () {
|
||||
$this->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255', 'unique:'.(new User)->getTable().',email,'.Auth::id()],
|
||||
]);
|
||||
|
||||
$user = User::findOrFail(Auth::id());
|
||||
$user->update([
|
||||
'name' => $this->name,
|
||||
'email' => $this->email,
|
||||
]);
|
||||
$user = User::findOrFail(Auth::id());
|
||||
$user->update([
|
||||
'name' => $this->name,
|
||||
'email' => $this->email,
|
||||
]);
|
||||
|
||||
$this->dispatch('profile-updated');
|
||||
Flux::toast(text: __('hub::hub.settings.messages.profile_updated'), variant: 'success');
|
||||
$this->dispatch('profile-updated');
|
||||
Flux::toast(text: __('hub::hub.settings.messages.profile_updated'), variant: 'success');
|
||||
});
|
||||
}
|
||||
|
||||
public function updatePreferences(): void
|
||||
|
|
@ -163,22 +174,24 @@ class Settings extends Component
|
|||
|
||||
public function updatePassword(): void
|
||||
{
|
||||
$this->validate([
|
||||
'current_password' => ['required', 'current_password'],
|
||||
'new_password' => ['required', 'confirmed', Password::defaults()],
|
||||
]);
|
||||
$this->rateLimit('password-change', 5, function () {
|
||||
$this->validate([
|
||||
'current_password' => ['required', 'current_password'],
|
||||
'new_password' => ['required', 'confirmed', Password::defaults()],
|
||||
]);
|
||||
|
||||
$user = User::findOrFail(Auth::id());
|
||||
$user->update([
|
||||
'password' => Hash::make($this->new_password),
|
||||
]);
|
||||
$user = User::findOrFail(Auth::id());
|
||||
$user->update([
|
||||
'password' => Hash::make($this->new_password),
|
||||
]);
|
||||
|
||||
$this->current_password = '';
|
||||
$this->new_password = '';
|
||||
$this->new_password_confirmation = '';
|
||||
$this->current_password = '';
|
||||
$this->new_password = '';
|
||||
$this->new_password_confirmation = '';
|
||||
|
||||
$this->dispatch('password-updated');
|
||||
Flux::toast(text: __('hub::hub.settings.messages.password_updated'), variant: 'success');
|
||||
$this->dispatch('password-updated');
|
||||
Flux::toast(text: __('hub::hub.settings.messages.password_updated'), variant: 'success');
|
||||
});
|
||||
}
|
||||
|
||||
public function enableTwoFactor(): void
|
||||
|
|
@ -213,20 +226,22 @@ class Settings extends Component
|
|||
|
||||
public function requestAccountDeletion(): void
|
||||
{
|
||||
// Get the base user model for the app
|
||||
$user = \Core\Tenant\Models\User::findOrFail(Auth::id());
|
||||
$this->rateLimit('account-deletion', 3, function () {
|
||||
// Get the base user model for the app
|
||||
$user = \Core\Tenant\Models\User::findOrFail(Auth::id());
|
||||
|
||||
// Create the deletion request
|
||||
$deletionRequest = AccountDeletionRequest::createForUser($user, $this->deleteReason ?: null);
|
||||
// Create the deletion request
|
||||
$deletionRequest = AccountDeletionRequest::createForUser($user, $this->deleteReason ?: null);
|
||||
|
||||
// Send confirmation email
|
||||
Mail::to($user->email)->send(new AccountDeletionRequested($deletionRequest));
|
||||
// Send confirmation email
|
||||
Mail::to($user->email)->send(new AccountDeletionRequested($deletionRequest));
|
||||
|
||||
$this->pendingDeletion = $deletionRequest;
|
||||
$this->showDeleteConfirmation = false;
|
||||
$this->deleteReason = '';
|
||||
$this->pendingDeletion = $deletionRequest;
|
||||
$this->showDeleteConfirmation = false;
|
||||
$this->deleteReason = '';
|
||||
|
||||
Flux::toast(text: __('hub::hub.settings.messages.deletion_scheduled'), variant: 'warning');
|
||||
Flux::toast(text: __('hub::hub.settings.messages.deletion_scheduled'), variant: 'warning');
|
||||
});
|
||||
}
|
||||
|
||||
public function cancelAccountDeletion(): void
|
||||
|
|
|
|||
|
|
@ -10,10 +10,12 @@ use Livewire\Attributes\Computed;
|
|||
use Livewire\Attributes\Title;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use Website\Hub\Concerns\HasRateLimiting;
|
||||
|
||||
#[Title('Waitlist')]
|
||||
class WaitlistManager extends Component
|
||||
{
|
||||
use HasRateLimiting;
|
||||
use WithPagination;
|
||||
|
||||
// Filters
|
||||
|
|
@ -68,19 +70,21 @@ class WaitlistManager extends Component
|
|||
*/
|
||||
public function sendInvite(int $id): void
|
||||
{
|
||||
$entry = WaitlistEntry::findOrFail($id);
|
||||
$this->rateLimit('admin-mutation', 10, function () use ($id) {
|
||||
$entry = WaitlistEntry::findOrFail($id);
|
||||
|
||||
if ($entry->isInvited()) {
|
||||
session()->flash('error', 'This person has already been invited.');
|
||||
if ($entry->isInvited()) {
|
||||
session()->flash('error', 'This person has already been invited.');
|
||||
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$entry->generateInviteCode();
|
||||
$entry->notify(new WaitlistInviteNotification($entry));
|
||||
$entry->generateInviteCode();
|
||||
$entry->notify(new WaitlistInviteNotification($entry));
|
||||
|
||||
session()->flash('message', "Invite sent to {$entry->email}");
|
||||
$this->refreshStats();
|
||||
session()->flash('message', "Invite sent to {$entry->email}");
|
||||
$this->refreshStats();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -88,28 +92,30 @@ class WaitlistManager extends Component
|
|||
*/
|
||||
public function sendBulkInvites(): void
|
||||
{
|
||||
$entries = WaitlistEntry::whereIn('id', $this->selected)
|
||||
->whereNull('invited_at')
|
||||
->get();
|
||||
$this->rateLimit('admin-mutation', 10, function () {
|
||||
$entries = WaitlistEntry::whereIn('id', $this->selected)
|
||||
->whereNull('invited_at')
|
||||
->get();
|
||||
|
||||
if ($entries->isEmpty()) {
|
||||
session()->flash('error', 'No pending entries selected.');
|
||||
if ($entries->isEmpty()) {
|
||||
session()->flash('error', 'No pending entries selected.');
|
||||
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
foreach ($entries as $entry) {
|
||||
$entry->generateInviteCode();
|
||||
$entry->notify(new WaitlistInviteNotification($entry));
|
||||
$count++;
|
||||
}
|
||||
$count = 0;
|
||||
foreach ($entries as $entry) {
|
||||
$entry->generateInviteCode();
|
||||
$entry->notify(new WaitlistInviteNotification($entry));
|
||||
$count++;
|
||||
}
|
||||
|
||||
$this->selected = [];
|
||||
$this->selectAll = false;
|
||||
$this->selected = [];
|
||||
$this->selectAll = false;
|
||||
|
||||
session()->flash('message', "Sent {$count} invite(s) successfully.");
|
||||
$this->refreshStats();
|
||||
session()->flash('message', "Sent {$count} invite(s) successfully.");
|
||||
$this->refreshStats();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -141,18 +147,20 @@ class WaitlistManager extends Component
|
|||
*/
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$entry = WaitlistEntry::findOrFail($id);
|
||||
$this->rateLimit('admin-mutation', 10, function () use ($id) {
|
||||
$entry = WaitlistEntry::findOrFail($id);
|
||||
|
||||
if ($entry->hasConverted()) {
|
||||
session()->flash('error', 'Cannot delete entries that have converted to users.');
|
||||
if ($entry->hasConverted()) {
|
||||
session()->flash('error', 'Cannot delete entries that have converted to users.');
|
||||
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$entry->delete();
|
||||
$entry->delete();
|
||||
|
||||
session()->flash('message', 'Entry deleted.');
|
||||
$this->refreshStats();
|
||||
session()->flash('message', 'Entry deleted.');
|
||||
$this->refreshStats();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -171,30 +179,32 @@ class WaitlistManager extends Component
|
|||
*/
|
||||
public function export()
|
||||
{
|
||||
$entries = $this->getFilteredQuery()->get();
|
||||
return $this->rateLimit('admin-export', 5, function () {
|
||||
$entries = $this->getFilteredQuery()->get();
|
||||
|
||||
$csv = "Email,Name,Interest,Source,Status,Signed Up,Invited,Registered\n";
|
||||
$csv = "Email,Name,Interest,Source,Status,Signed Up,Invited,Registered\n";
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
$status = $entry->hasConverted() ? 'Converted' : ($entry->isInvited() ? 'Invited' : 'Pending');
|
||||
$csv .= sprintf(
|
||||
"%s,%s,%s,%s,%s,%s,%s,%s\n",
|
||||
$entry->email,
|
||||
$entry->name ?? '',
|
||||
$entry->interest ?? '',
|
||||
$entry->source ?? '',
|
||||
$status,
|
||||
$entry->created_at->format('Y-m-d'),
|
||||
$entry->invited_at?->format('Y-m-d') ?? '',
|
||||
$entry->registered_at?->format('Y-m-d') ?? ''
|
||||
);
|
||||
}
|
||||
foreach ($entries as $entry) {
|
||||
$status = $entry->hasConverted() ? 'Converted' : ($entry->isInvited() ? 'Invited' : 'Pending');
|
||||
$csv .= sprintf(
|
||||
"%s,%s,%s,%s,%s,%s,%s,%s\n",
|
||||
$entry->email,
|
||||
$entry->name ?? '',
|
||||
$entry->interest ?? '',
|
||||
$entry->source ?? '',
|
||||
$status,
|
||||
$entry->created_at->format('Y-m-d'),
|
||||
$entry->invited_at?->format('Y-m-d') ?? '',
|
||||
$entry->registered_at?->format('Y-m-d') ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
return response()->streamDownload(function () use ($csv) {
|
||||
echo $csv;
|
||||
}, 'waitlist-export-'.now()->format('Y-m-d').'.csv', [
|
||||
'Content-Type' => 'text/csv',
|
||||
]);
|
||||
return response()->streamDownload(function () use ($csv) {
|
||||
echo $csv;
|
||||
}, 'waitlist-export-'.now()->format('Y-m-d').'.csv', [
|
||||
'Content-Type' => 'text/csv',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
protected function refreshStats(): void
|
||||
|
|
|
|||
280
tests/Feature/Security/RateLimitingTest.php
Normal file
280
tests/Feature/Security/RateLimitingTest.php
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Core PHP Framework
|
||||
*
|
||||
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||
* See LICENSE file for details.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Livewire\Component;
|
||||
use Livewire\Livewire;
|
||||
use Website\Hub\Concerns\HasRateLimiting;
|
||||
|
||||
// =============================================================================
|
||||
// Test Double Components
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Component with actionMessage/actionType properties (PlatformUser pattern).
|
||||
*/
|
||||
class RateLimitedActionComponent extends Component
|
||||
{
|
||||
use HasRateLimiting;
|
||||
|
||||
public string $actionMessage = '';
|
||||
|
||||
public string $actionType = '';
|
||||
|
||||
public int $executionCount = 0;
|
||||
|
||||
public function mutate(): void
|
||||
{
|
||||
$this->rateLimit('test-mutation', 3, function () {
|
||||
$this->executionCount++;
|
||||
$this->actionMessage = 'Action executed.';
|
||||
$this->actionType = 'success';
|
||||
});
|
||||
}
|
||||
|
||||
public function export()
|
||||
{
|
||||
return $this->rateLimit('test-export', 2, function () {
|
||||
$this->executionCount++;
|
||||
|
||||
return 'export-data';
|
||||
});
|
||||
}
|
||||
|
||||
public function destroy(): void
|
||||
{
|
||||
$this->rateLimit('test-deletion', 1, function () {
|
||||
$this->executionCount++;
|
||||
$this->actionMessage = 'Deleted.';
|
||||
$this->actionType = 'success';
|
||||
});
|
||||
}
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
return <<<'HTML'
|
||||
<div>
|
||||
<span>Executions: {{ $executionCount }}</span>
|
||||
<span>Message: {{ $actionMessage }}</span>
|
||||
<span>Type: {{ $actionType }}</span>
|
||||
</div>
|
||||
HTML;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Component without actionMessage/actionType (session flash fallback).
|
||||
*/
|
||||
class RateLimitedSessionComponent extends Component
|
||||
{
|
||||
use HasRateLimiting;
|
||||
|
||||
public int $executionCount = 0;
|
||||
|
||||
public function mutate(): void
|
||||
{
|
||||
$this->rateLimit('test-session-mutation', 2, function () {
|
||||
$this->executionCount++;
|
||||
});
|
||||
}
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
return <<<'HTML'
|
||||
<div>
|
||||
<span>Executions: {{ $executionCount }}</span>
|
||||
</div>
|
||||
HTML;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Rate Limiting Enforcement Tests
|
||||
// =============================================================================
|
||||
|
||||
beforeEach(function () {
|
||||
RateLimiter::clear('test-mutation:1');
|
||||
RateLimiter::clear('test-export:1');
|
||||
RateLimiter::clear('test-deletion:1');
|
||||
RateLimiter::clear('test-session-mutation:1');
|
||||
});
|
||||
|
||||
describe('Rate limiting enforcement', function () {
|
||||
it('allows actions within the rate limit', function () {
|
||||
$this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1]));
|
||||
|
||||
Livewire::test(RateLimitedActionComponent::class)
|
||||
->call('mutate')
|
||||
->assertSet('executionCount', 1)
|
||||
->assertSet('actionMessage', 'Action executed.')
|
||||
->assertSet('actionType', 'success')
|
||||
->call('mutate')
|
||||
->assertSet('executionCount', 2)
|
||||
->call('mutate')
|
||||
->assertSet('executionCount', 3);
|
||||
});
|
||||
|
||||
it('blocks actions exceeding the rate limit', function () {
|
||||
$this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1]));
|
||||
|
||||
$component = Livewire::test(RateLimitedActionComponent::class);
|
||||
|
||||
// Execute up to the limit
|
||||
$component->call('mutate')
|
||||
->call('mutate')
|
||||
->call('mutate')
|
||||
->assertSet('executionCount', 3);
|
||||
|
||||
// Fourth call should be blocked
|
||||
$component->call('mutate')
|
||||
->assertSet('executionCount', 3) // Not incremented
|
||||
->assertSet('actionType', 'error')
|
||||
->assertSet('actionMessage', fn (string $msg) => str_contains($msg, 'Too many requests'));
|
||||
});
|
||||
|
||||
it('blocks export actions exceeding the rate limit', function () {
|
||||
$this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1]));
|
||||
|
||||
$component = Livewire::test(RateLimitedActionComponent::class);
|
||||
|
||||
// Execute up to the limit (2 for exports)
|
||||
$component->call('export')
|
||||
->assertSet('executionCount', 1)
|
||||
->call('export')
|
||||
->assertSet('executionCount', 2);
|
||||
|
||||
// Third call should be blocked
|
||||
$component->call('export')
|
||||
->assertSet('executionCount', 2) // Not incremented
|
||||
->assertSet('actionType', 'error');
|
||||
});
|
||||
|
||||
it('enforces strict limits on destructive actions', function () {
|
||||
$this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1]));
|
||||
|
||||
$component = Livewire::test(RateLimitedActionComponent::class);
|
||||
|
||||
// Execute up to the limit (1 for deletions)
|
||||
$component->call('destroy')
|
||||
->assertSet('executionCount', 1)
|
||||
->assertSet('actionMessage', 'Deleted.');
|
||||
|
||||
// Second call should be blocked
|
||||
$component->call('destroy')
|
||||
->assertSet('executionCount', 1) // Not incremented
|
||||
->assertSet('actionType', 'error')
|
||||
->assertSet('actionMessage', fn (string $msg) => str_contains($msg, 'Too many requests'));
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Rate Limit Key Scoping Tests
|
||||
// =============================================================================
|
||||
|
||||
describe('Rate limit key scoping', function () {
|
||||
it('scopes rate limits per user', function () {
|
||||
// User 1 exhausts their limit
|
||||
$this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1]));
|
||||
RateLimiter::clear('test-deletion:1');
|
||||
RateLimiter::clear('test-deletion:2');
|
||||
|
||||
$component1 = Livewire::test(RateLimitedActionComponent::class);
|
||||
$component1->call('destroy')
|
||||
->assertSet('executionCount', 1);
|
||||
$component1->call('destroy')
|
||||
->assertSet('executionCount', 1); // Blocked
|
||||
|
||||
// User 2 should not be affected
|
||||
$this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 2]));
|
||||
|
||||
Livewire::test(RateLimitedActionComponent::class)
|
||||
->call('destroy')
|
||||
->assertSet('executionCount', 1) // User 2's own count
|
||||
->assertSet('actionMessage', 'Deleted.');
|
||||
});
|
||||
|
||||
it('uses separate limits for different action types', function () {
|
||||
$this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1]));
|
||||
|
||||
$component = Livewire::test(RateLimitedActionComponent::class);
|
||||
|
||||
// Exhaust deletion limit (1)
|
||||
$component->call('destroy')
|
||||
->assertSet('executionCount', 1);
|
||||
|
||||
// Mutation limit (3) should still be available
|
||||
$component->call('mutate')
|
||||
->assertSet('executionCount', 2)
|
||||
->assertSet('actionMessage', 'Action executed.')
|
||||
->assertSet('actionType', 'success');
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// User Feedback Tests
|
||||
// =============================================================================
|
||||
|
||||
describe('User feedback when rate limited', function () {
|
||||
it('shows error message with retry time via actionMessage', function () {
|
||||
$this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1]));
|
||||
|
||||
$component = Livewire::test(RateLimitedActionComponent::class);
|
||||
|
||||
// Exhaust limit
|
||||
$component->call('destroy');
|
||||
|
||||
// Next call should show error with seconds
|
||||
$component->call('destroy')
|
||||
->assertSet('actionType', 'error')
|
||||
->assertSet('actionMessage', fn (string $msg) => str_contains($msg, 'Too many requests')
|
||||
&& str_contains($msg, 'seconds'));
|
||||
});
|
||||
|
||||
it('flashes error to session when component lacks actionMessage property', function () {
|
||||
$this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1]));
|
||||
|
||||
$component = Livewire::test(RateLimitedSessionComponent::class);
|
||||
|
||||
// Exhaust limit (2)
|
||||
$component->call('mutate')->call('mutate');
|
||||
|
||||
// Third call should be blocked and flash to session
|
||||
$component->call('mutate')
|
||||
->assertSet('executionCount', 2); // Not incremented
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Rate Limit Reset Tests
|
||||
// =============================================================================
|
||||
|
||||
describe('Rate limit reset', function () {
|
||||
it('allows actions after rate limit window resets', function () {
|
||||
$this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1]));
|
||||
|
||||
$component = Livewire::test(RateLimitedActionComponent::class);
|
||||
|
||||
// Exhaust limit
|
||||
$component->call('destroy')
|
||||
->assertSet('executionCount', 1);
|
||||
$component->call('destroy')
|
||||
->assertSet('executionCount', 1); // Blocked
|
||||
|
||||
// Clear the rate limiter (simulates window expiry)
|
||||
RateLimiter::clear('test-deletion:1');
|
||||
|
||||
// Should work again
|
||||
$component->call('destroy')
|
||||
->assertSet('executionCount', 2)
|
||||
->assertSet('actionMessage', 'Deleted.')
|
||||
->assertSet('actionType', 'success');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue