Compare commits
5 commits
security/i
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 63abc5f99a | |||
| 8922683bcf | |||
| ee383bbe3f | |||
| 498bceab88 | |||
| 9ae0055f33 |
9 changed files with 1111 additions and 294 deletions
|
|
@ -91,7 +91,7 @@ class TeapotController
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whitelist headers useful for bot detection before storing.
|
* Remove sensitive headers and enforce size limits before storing.
|
||||||
*/
|
*/
|
||||||
protected function sanitizeHeaders(array $headers): array
|
protected function sanitizeHeaders(array $headers): array
|
||||||
{
|
{
|
||||||
|
|
@ -109,7 +109,14 @@ class TeapotController
|
||||||
'x-client-ip',
|
'x-client-ip',
|
||||||
];
|
];
|
||||||
|
|
||||||
return array_intersect_key($headers, array_flip($allowed));
|
$headers = array_intersect_key($headers, array_flip($allowed));
|
||||||
|
|
||||||
|
// Enforce header count limit before passing to the model
|
||||||
|
if (count($headers) > HoneypotHit::HEADERS_MAX_COUNT) {
|
||||||
|
$headers = array_slice($headers, 0, HoneypotHit::HEADERS_MAX_COUNT, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,61 @@ class HoneypotHit extends Model
|
||||||
'is_bot' => 'boolean',
|
'is_bot' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum number of headers to store per hit.
|
||||||
|
*/
|
||||||
|
public const HEADERS_MAX_COUNT = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum size in bytes for the serialised headers JSON (16 KB).
|
||||||
|
*/
|
||||||
|
public const HEADERS_MAX_SIZE = 16_384;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and set the headers attribute, enforcing count and size limits.
|
||||||
|
*/
|
||||||
|
public function setHeadersAttribute(mixed $value): void
|
||||||
|
{
|
||||||
|
if (is_null($value)) {
|
||||||
|
$this->attributes['headers'] = null;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($value)) {
|
||||||
|
$decoded = json_decode($value, true);
|
||||||
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||||
|
$this->attributes['headers'] = null;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$value = $decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_array($value)) {
|
||||||
|
$this->attributes['headers'] = null;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit header count
|
||||||
|
if (count($value) > self::HEADERS_MAX_COUNT) {
|
||||||
|
$value = array_slice($value, 0, self::HEADERS_MAX_COUNT, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check total size and truncate further if needed
|
||||||
|
$json = json_encode($value);
|
||||||
|
if (strlen($json) > self::HEADERS_MAX_SIZE) {
|
||||||
|
// Progressively reduce until under limit
|
||||||
|
while (strlen($json) > self::HEADERS_MAX_SIZE && count($value) > 0) {
|
||||||
|
array_pop($value);
|
||||||
|
$json = json_encode($value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->attributes['headers'] = $json;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Severity levels for honeypot hits.
|
* Severity levels for honeypot hits.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ namespace Core\Mod\Hub\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
class Service extends Model
|
class Service extends Model
|
||||||
{
|
{
|
||||||
|
|
@ -121,6 +122,55 @@ class Service extends Model
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum size in bytes for the serialised metadata JSON (64 KB).
|
||||||
|
*/
|
||||||
|
public const METADATA_MAX_SIZE = 65_535;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum number of top-level keys allowed in metadata.
|
||||||
|
*/
|
||||||
|
public const METADATA_MAX_KEYS = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and set the metadata attribute.
|
||||||
|
*/
|
||||||
|
public function setMetadataAttribute(mixed $value): void
|
||||||
|
{
|
||||||
|
if (is_null($value)) {
|
||||||
|
$this->attributes['metadata'] = null;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($value)) {
|
||||||
|
$decoded = json_decode($value, true);
|
||||||
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||||
|
throw new InvalidArgumentException('Metadata must be valid JSON');
|
||||||
|
}
|
||||||
|
$value = $decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_array($value)) {
|
||||||
|
throw new InvalidArgumentException('Metadata must be an array or null');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($value) > self::METADATA_MAX_KEYS) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
'Metadata exceeds maximum of ' . self::METADATA_MAX_KEYS . ' keys'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = json_encode($value);
|
||||||
|
if (strlen($json) > self::METADATA_MAX_SIZE) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
'Metadata exceeds maximum size of ' . self::METADATA_MAX_SIZE . ' bytes'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->attributes['metadata'] = $json;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a specific metadata key exists.
|
* Check if a specific metadata key exists.
|
||||||
*/
|
*/
|
||||||
|
|
@ -139,9 +189,17 @@ class Service extends Model
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set a metadata value.
|
* Set a metadata value.
|
||||||
|
*
|
||||||
|
* Keys must be non-empty and contain only alphanumeric characters, underscores, and hyphens.
|
||||||
*/
|
*/
|
||||||
public function setMeta(string $key, mixed $value): void
|
public function setMeta(string $key, mixed $value): void
|
||||||
{
|
{
|
||||||
|
if (empty($key) || ! preg_match('/^[a-zA-Z0-9_-]+$/', $key)) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
'Metadata key must be non-empty and contain only alphanumeric characters, underscores, and hyphens'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$metadata = $this->metadata ?? [];
|
$metadata = $this->metadata ?? [];
|
||||||
$metadata[$key] = $value;
|
$metadata[$key] = $value;
|
||||||
$this->metadata = $metadata;
|
$this->metadata = $metadata;
|
||||||
|
|
|
||||||
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\User;
|
||||||
use Core\Tenant\Models\Workspace;
|
use Core\Tenant\Models\Workspace;
|
||||||
use Core\Tenant\Services\EntitlementService;
|
use Core\Tenant\Services\EntitlementService;
|
||||||
|
use Website\Hub\Concerns\HasRateLimiting;
|
||||||
|
|
||||||
class PlatformUser extends Component
|
class PlatformUser extends Component
|
||||||
{
|
{
|
||||||
|
use HasRateLimiting;
|
||||||
|
|
||||||
public User $user;
|
public User $user;
|
||||||
|
|
||||||
// Editable fields
|
// Editable fields
|
||||||
|
|
@ -80,27 +83,31 @@ class PlatformUser extends Component
|
||||||
|
|
||||||
public function saveTier(): void
|
public function saveTier(): void
|
||||||
{
|
{
|
||||||
$this->user->tier = UserTier::from($this->editingTier);
|
$this->rateLimit('admin-tier-change', 10, function () {
|
||||||
$this->user->save();
|
$this->user->tier = UserTier::from($this->editingTier);
|
||||||
|
$this->user->save();
|
||||||
|
|
||||||
$this->actionMessage = "Tier updated to {$this->editingTier}.";
|
$this->actionMessage = "Tier updated to {$this->editingTier}.";
|
||||||
$this->actionType = 'success';
|
$this->actionType = 'success';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function saveVerification(): void
|
public function saveVerification(): void
|
||||||
{
|
{
|
||||||
if ($this->editingVerified && ! $this->user->email_verified_at) {
|
$this->rateLimit('admin-verification', 10, function () {
|
||||||
$this->user->email_verified_at = now();
|
if ($this->editingVerified && ! $this->user->email_verified_at) {
|
||||||
} elseif (! $this->editingVerified) {
|
$this->user->email_verified_at = now();
|
||||||
$this->user->email_verified_at = null;
|
} elseif (! $this->editingVerified) {
|
||||||
}
|
$this->user->email_verified_at = null;
|
||||||
|
}
|
||||||
|
|
||||||
$this->user->save();
|
$this->user->save();
|
||||||
|
|
||||||
$this->actionMessage = $this->editingVerified
|
$this->actionMessage = $this->editingVerified
|
||||||
? 'Email marked as verified.'
|
? 'Email marked as verified.'
|
||||||
: 'Email verification removed.';
|
: 'Email verification removed.';
|
||||||
$this->actionType = 'success';
|
$this->actionType = 'success';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function resendVerification(): void
|
public function resendVerification(): void
|
||||||
|
|
@ -123,21 +130,23 @@ class PlatformUser extends Component
|
||||||
*/
|
*/
|
||||||
public function exportUserData()
|
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', [
|
Log::info('GDPR data export performed by admin', [
|
||||||
'admin_id' => auth()->id(),
|
'admin_id' => auth()->id(),
|
||||||
'target_user_id' => $this->user->id,
|
'target_user_id' => $this->user->id,
|
||||||
'target_email' => $this->user->email,
|
'target_email' => $this->user->email,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return response()->streamDownload(function () use ($data) {
|
return response()->streamDownload(function () use ($data) {
|
||||||
echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||||
}, $filename, [
|
}, $filename, [
|
||||||
'Content-Type' => 'application/json',
|
'Content-Type' => 'application/json',
|
||||||
]);
|
]);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -240,32 +249,34 @@ class PlatformUser extends Component
|
||||||
*/
|
*/
|
||||||
public function scheduleDelete(): void
|
public function scheduleDelete(): void
|
||||||
{
|
{
|
||||||
if ($this->user->isHades() && $this->user->id === auth()->id()) {
|
$this->rateLimit('admin-deletion', 3, function () {
|
||||||
$this->actionMessage = 'You cannot delete your own Hades account from here.';
|
if ($this->user->isHades() && $this->user->id === auth()->id()) {
|
||||||
$this->actionType = 'error';
|
$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;
|
$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
|
public function anonymizeUser(): void
|
||||||
{
|
{
|
||||||
if ($this->user->isHades() && $this->user->id === auth()->id()) {
|
$this->rateLimit('admin-deletion', 3, function () {
|
||||||
$this->actionMessage = 'You cannot anonymize your own account.';
|
if ($this->user->isHades() && $this->user->id === auth()->id()) {
|
||||||
$this->actionType = 'error';
|
$this->actionMessage = 'You cannot anonymize your own account.';
|
||||||
|
$this->actionType = 'error';
|
||||||
|
|
||||||
return;
|
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cancel any pending deletions
|
$originalEmail = $this->user->email;
|
||||||
AccountDeletionRequest::where('user_id', $this->user->id)
|
$anonymizedId = 'anon_'.$this->user->id.'_'.now()->timestamp;
|
||||||
->whereNull('completed_at')
|
|
||||||
->whereNull('cancelled_at')
|
DB::transaction(function () use ($anonymizedId) {
|
||||||
->update(['cancelled_at' => now()]);
|
$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
|
public function provisionPackage(): void
|
||||||
{
|
{
|
||||||
if (! $this->selectedWorkspaceId || ! $this->selectedPackageCode) {
|
$this->rateLimit('admin-entitlement', 10, function () {
|
||||||
$this->actionMessage = 'Please select a workspace and package.';
|
if (! $this->selectedWorkspaceId || ! $this->selectedPackageCode) {
|
||||||
$this->actionType = 'warning';
|
$this->actionMessage = 'Please select a workspace and package.';
|
||||||
|
$this->actionType = 'warning';
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$workspace = Workspace::findOrFail($this->selectedWorkspaceId);
|
$workspace = Workspace::findOrFail($this->selectedWorkspaceId);
|
||||||
$package = Package::where('code', $this->selectedPackageCode)->firstOrFail();
|
$package = Package::where('code', $this->selectedPackageCode)->firstOrFail();
|
||||||
|
|
||||||
$entitlements = app(EntitlementService::class);
|
$entitlements = app(EntitlementService::class);
|
||||||
$entitlements->provisionPackage($workspace, $this->selectedPackageCode, [
|
$entitlements->provisionPackage($workspace, $this->selectedPackageCode, [
|
||||||
'source' => 'admin',
|
'source' => 'admin',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Log::info('Package provisioned by admin', [
|
Log::info('Package provisioned by admin', [
|
||||||
'admin_id' => auth()->id(),
|
'admin_id' => auth()->id(),
|
||||||
'user_id' => $this->user->id,
|
'user_id' => $this->user->id,
|
||||||
'workspace_id' => $workspace->id,
|
'workspace_id' => $workspace->id,
|
||||||
'package_code' => $this->selectedPackageCode,
|
'package_code' => $this->selectedPackageCode,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->actionMessage = "Package '{$package->name}' provisioned to workspace '{$workspace->name}'.";
|
$this->actionMessage = "Package '{$package->name}' provisioned to workspace '{$workspace->name}'.";
|
||||||
$this->actionType = 'success';
|
$this->actionType = 'success';
|
||||||
|
|
||||||
$this->closePackageModal();
|
$this->closePackageModal();
|
||||||
unset($this->workspaces); // Clear computed cache
|
unset($this->workspaces); // Clear computed cache
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -482,34 +497,36 @@ class PlatformUser extends Component
|
||||||
*/
|
*/
|
||||||
public function revokePackage(int $workspaceId, string $packageCode): void
|
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
|
// Verify this belongs to one of the user's workspaces
|
||||||
if (! $this->user->hostWorkspaces->contains($workspace)) {
|
if (! $this->user->hostWorkspaces->contains($workspace)) {
|
||||||
$this->actionMessage = 'This workspace does not belong to this user.';
|
$this->actionMessage = 'This workspace does not belong to this user.';
|
||||||
$this->actionType = 'error';
|
$this->actionType = 'error';
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$package = Package::where('code', $packageCode)->first();
|
$package = Package::where('code', $packageCode)->first();
|
||||||
$packageName = $package?->name ?? $packageCode;
|
$packageName = $package?->name ?? $packageCode;
|
||||||
$workspaceName = $workspace->name;
|
$workspaceName = $workspace->name;
|
||||||
|
|
||||||
$entitlements = app(EntitlementService::class);
|
$entitlements = app(EntitlementService::class);
|
||||||
$entitlements->revokePackage($workspace, $packageCode, 'admin');
|
$entitlements->revokePackage($workspace, $packageCode, 'admin');
|
||||||
|
|
||||||
Log::info('Package revoked by admin', [
|
Log::info('Package revoked by admin', [
|
||||||
'admin_id' => auth()->id(),
|
'admin_id' => auth()->id(),
|
||||||
'user_id' => $this->user->id,
|
'user_id' => $this->user->id,
|
||||||
'workspace_id' => $workspace->id,
|
'workspace_id' => $workspace->id,
|
||||||
'package_code' => $packageCode,
|
'package_code' => $packageCode,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->actionMessage = "Package '{$packageName}' revoked from workspace '{$workspaceName}'.";
|
$this->actionMessage = "Package '{$packageName}' revoked from workspace '{$workspaceName}'.";
|
||||||
$this->actionType = 'success';
|
$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
|
public function provisionEntitlement(): void
|
||||||
{
|
{
|
||||||
if (! $this->entitlementWorkspaceId || ! $this->entitlementFeatureCode) {
|
$this->rateLimit('admin-entitlement', 10, function () {
|
||||||
$this->actionMessage = 'Please select a workspace and feature.';
|
if (! $this->entitlementWorkspaceId || ! $this->entitlementFeatureCode) {
|
||||||
$this->actionType = 'warning';
|
$this->actionMessage = 'Please select a workspace and feature.';
|
||||||
|
$this->actionType = 'warning';
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$workspace = Workspace::findOrFail($this->entitlementWorkspaceId);
|
$workspace = Workspace::findOrFail($this->entitlementWorkspaceId);
|
||||||
$feature = Feature::where('code', $this->entitlementFeatureCode)->first();
|
$feature = Feature::where('code', $this->entitlementFeatureCode)->first();
|
||||||
|
|
||||||
if (! $feature) {
|
if (! $feature) {
|
||||||
$this->actionMessage = 'Feature not found.';
|
$this->actionMessage = 'Feature not found.';
|
||||||
$this->actionType = 'error';
|
$this->actionType = 'error';
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify this belongs to one of the user's workspaces
|
// Verify this belongs to one of the user's workspaces
|
||||||
if (! $this->user->hostWorkspaces->contains($workspace)) {
|
if (! $this->user->hostWorkspaces->contains($workspace)) {
|
||||||
$this->actionMessage = 'This workspace does not belong to this user.';
|
$this->actionMessage = 'This workspace does not belong to this user.';
|
||||||
$this->actionType = 'error';
|
$this->actionType = 'error';
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$options = [
|
$options = [
|
||||||
'source' => 'admin',
|
'source' => 'admin',
|
||||||
'boost_type' => match ($this->entitlementType) {
|
'boost_type' => match ($this->entitlementType) {
|
||||||
'enable' => Boost::BOOST_TYPE_ENABLE,
|
'enable' => Boost::BOOST_TYPE_ENABLE,
|
||||||
'add_limit' => Boost::BOOST_TYPE_ADD_LIMIT,
|
'add_limit' => Boost::BOOST_TYPE_ADD_LIMIT,
|
||||||
'unlimited' => Boost::BOOST_TYPE_UNLIMITED,
|
'unlimited' => Boost::BOOST_TYPE_UNLIMITED,
|
||||||
default => Boost::BOOST_TYPE_ENABLE,
|
default => Boost::BOOST_TYPE_ENABLE,
|
||||||
},
|
},
|
||||||
'duration_type' => $this->entitlementDuration === 'permanent'
|
'duration_type' => $this->entitlementDuration === 'permanent'
|
||||||
? Boost::DURATION_PERMANENT
|
? Boost::DURATION_PERMANENT
|
||||||
: Boost::DURATION_DURATION,
|
: Boost::DURATION_DURATION,
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($this->entitlementType === 'add_limit' && $this->entitlementLimit) {
|
if ($this->entitlementType === 'add_limit' && $this->entitlementLimit) {
|
||||||
$options['limit_value'] = $this->entitlementLimit;
|
$options['limit_value'] = $this->entitlementLimit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->entitlementDuration === 'duration' && $this->entitlementExpiresAt) {
|
if ($this->entitlementDuration === 'duration' && $this->entitlementExpiresAt) {
|
||||||
$options['expires_at'] = $this->entitlementExpiresAt;
|
$options['expires_at'] = $this->entitlementExpiresAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
$entitlements = app(EntitlementService::class);
|
$entitlements = app(EntitlementService::class);
|
||||||
$entitlements->provisionBoost($workspace, $this->entitlementFeatureCode, $options);
|
$entitlements->provisionBoost($workspace, $this->entitlementFeatureCode, $options);
|
||||||
|
|
||||||
Log::info('Entitlement provisioned by admin', [
|
Log::info('Entitlement provisioned by admin', [
|
||||||
'admin_id' => auth()->id(),
|
'admin_id' => auth()->id(),
|
||||||
'user_id' => $this->user->id,
|
'user_id' => $this->user->id,
|
||||||
'workspace_id' => $workspace->id,
|
'workspace_id' => $workspace->id,
|
||||||
'feature_code' => $this->entitlementFeatureCode,
|
'feature_code' => $this->entitlementFeatureCode,
|
||||||
'type' => $this->entitlementType,
|
'type' => $this->entitlementType,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->actionMessage = "Entitlement '{$feature->name}' added to workspace '{$workspace->name}'.";
|
$this->actionMessage = "Entitlement '{$feature->name}' added to workspace '{$workspace->name}'.";
|
||||||
$this->actionType = 'success';
|
$this->actionType = 'success';
|
||||||
|
|
||||||
$this->closeEntitlementModal();
|
$this->closeEntitlementModal();
|
||||||
unset($this->workspaceEntitlements);
|
unset($this->workspaceEntitlements);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -655,34 +674,36 @@ class PlatformUser extends Component
|
||||||
*/
|
*/
|
||||||
public function removeBoost(int $boostId): void
|
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
|
// Verify this belongs to one of the user's workspaces
|
||||||
$workspace = $boost->workspace;
|
$workspace = $boost->workspace;
|
||||||
if (! $this->user->hostWorkspaces->contains($workspace)) {
|
if (! $this->user->hostWorkspaces->contains($workspace)) {
|
||||||
$this->actionMessage = 'This boost does not belong to this user.';
|
$this->actionMessage = 'This boost does not belong to this user.';
|
||||||
$this->actionType = 'error';
|
$this->actionType = 'error';
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$featureCode = $boost->feature_code;
|
$featureCode = $boost->feature_code;
|
||||||
$workspaceName = $workspace->name;
|
$workspaceName = $workspace->name;
|
||||||
|
|
||||||
$boost->update(['status' => Boost::STATUS_CANCELLED]);
|
$boost->update(['status' => Boost::STATUS_CANCELLED]);
|
||||||
|
|
||||||
Log::info('Boost removed by admin', [
|
Log::info('Boost removed by admin', [
|
||||||
'admin_id' => auth()->id(),
|
'admin_id' => auth()->id(),
|
||||||
'user_id' => $this->user->id,
|
'user_id' => $this->user->id,
|
||||||
'workspace_id' => $workspace->id,
|
'workspace_id' => $workspace->id,
|
||||||
'boost_id' => $boostId,
|
'boost_id' => $boostId,
|
||||||
'feature_code' => $featureCode,
|
'feature_code' => $featureCode,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->actionMessage = "Boost for '{$featureCode}' removed from workspace '{$workspaceName}'.";
|
$this->actionMessage = "Boost for '{$featureCode}' removed from workspace '{$workspaceName}'.";
|
||||||
$this->actionType = 'success';
|
$this->actionType = 'success';
|
||||||
|
|
||||||
unset($this->workspaceEntitlements);
|
unset($this->workspaceEntitlements);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,12 @@ use Core\Tenant\Mail\AccountDeletionRequested;
|
||||||
use Core\Tenant\Models\AccountDeletionRequest;
|
use Core\Tenant\Models\AccountDeletionRequest;
|
||||||
use Core\Tenant\Models\User;
|
use Core\Tenant\Models\User;
|
||||||
use Core\Tenant\Services\UserStatsService;
|
use Core\Tenant\Services\UserStatsService;
|
||||||
|
use Website\Hub\Concerns\HasRateLimiting;
|
||||||
|
|
||||||
class Settings extends Component
|
class Settings extends Component
|
||||||
{
|
{
|
||||||
|
use HasRateLimiting;
|
||||||
|
|
||||||
// Active section for sidebar navigation
|
// Active section for sidebar navigation
|
||||||
#[Url(as: 'tab')]
|
#[Url(as: 'tab')]
|
||||||
public string $activeSection = 'profile';
|
public string $activeSection = 'profile';
|
||||||
|
|
@ -108,6 +111,12 @@ class Settings extends Component
|
||||||
$this->timezones = UserStatsService::getTimezoneList();
|
$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
|
protected function getUserSetting(string $name, mixed $default = null): mixed
|
||||||
{
|
{
|
||||||
$setting = Setting::where('user_id', Auth::id())
|
$setting = Setting::where('user_id', Auth::id())
|
||||||
|
|
@ -119,19 +128,21 @@ class Settings extends Component
|
||||||
|
|
||||||
public function updateProfile(): void
|
public function updateProfile(): void
|
||||||
{
|
{
|
||||||
$this->validate([
|
$this->rateLimit('profile-update', 20, function () {
|
||||||
'name' => ['required', 'string', 'max:255'],
|
$this->validate([
|
||||||
'email' => ['required', 'email', 'max:255', 'unique:'.(new User)->getTable().',email,'.Auth::id()],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
]);
|
'email' => ['required', 'email', 'max:255', 'unique:'.(new User)->getTable().',email,'.Auth::id()],
|
||||||
|
]);
|
||||||
|
|
||||||
$user = User::findOrFail(Auth::id());
|
$user = User::findOrFail(Auth::id());
|
||||||
$user->update([
|
$user->update([
|
||||||
'name' => $this->name,
|
'name' => $this->name,
|
||||||
'email' => $this->email,
|
'email' => $this->email,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->dispatch('profile-updated');
|
$this->dispatch('profile-updated');
|
||||||
Flux::toast(text: __('hub::hub.settings.messages.profile_updated'), variant: 'success');
|
Flux::toast(text: __('hub::hub.settings.messages.profile_updated'), variant: 'success');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updatePreferences(): void
|
public function updatePreferences(): void
|
||||||
|
|
@ -163,22 +174,24 @@ class Settings extends Component
|
||||||
|
|
||||||
public function updatePassword(): void
|
public function updatePassword(): void
|
||||||
{
|
{
|
||||||
$this->validate([
|
$this->rateLimit('password-change', 5, function () {
|
||||||
'current_password' => ['required', 'current_password'],
|
$this->validate([
|
||||||
'new_password' => ['required', 'confirmed', Password::defaults()],
|
'current_password' => ['required', 'current_password'],
|
||||||
]);
|
'new_password' => ['required', 'confirmed', Password::defaults()],
|
||||||
|
]);
|
||||||
|
|
||||||
$user = User::findOrFail(Auth::id());
|
$user = User::findOrFail(Auth::id());
|
||||||
$user->update([
|
$user->update([
|
||||||
'password' => Hash::make($this->new_password),
|
'password' => Hash::make($this->new_password),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->current_password = '';
|
$this->current_password = '';
|
||||||
$this->new_password = '';
|
$this->new_password = '';
|
||||||
$this->new_password_confirmation = '';
|
$this->new_password_confirmation = '';
|
||||||
|
|
||||||
$this->dispatch('password-updated');
|
$this->dispatch('password-updated');
|
||||||
Flux::toast(text: __('hub::hub.settings.messages.password_updated'), variant: 'success');
|
Flux::toast(text: __('hub::hub.settings.messages.password_updated'), variant: 'success');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function enableTwoFactor(): void
|
public function enableTwoFactor(): void
|
||||||
|
|
@ -213,20 +226,22 @@ class Settings extends Component
|
||||||
|
|
||||||
public function requestAccountDeletion(): void
|
public function requestAccountDeletion(): void
|
||||||
{
|
{
|
||||||
// Get the base user model for the app
|
$this->rateLimit('account-deletion', 3, function () {
|
||||||
$user = \Core\Tenant\Models\User::findOrFail(Auth::id());
|
// Get the base user model for the app
|
||||||
|
$user = \Core\Tenant\Models\User::findOrFail(Auth::id());
|
||||||
|
|
||||||
// Create the deletion request
|
// Create the deletion request
|
||||||
$deletionRequest = AccountDeletionRequest::createForUser($user, $this->deleteReason ?: null);
|
$deletionRequest = AccountDeletionRequest::createForUser($user, $this->deleteReason ?: null);
|
||||||
|
|
||||||
// Send confirmation email
|
// Send confirmation email
|
||||||
Mail::to($user->email)->send(new AccountDeletionRequested($deletionRequest));
|
Mail::to($user->email)->send(new AccountDeletionRequested($deletionRequest));
|
||||||
|
|
||||||
$this->pendingDeletion = $deletionRequest;
|
$this->pendingDeletion = $deletionRequest;
|
||||||
$this->showDeleteConfirmation = false;
|
$this->showDeleteConfirmation = false;
|
||||||
$this->deleteReason = '';
|
$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
|
public function cancelAccountDeletion(): void
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,12 @@ use Livewire\Attributes\Computed;
|
||||||
use Livewire\Attributes\Title;
|
use Livewire\Attributes\Title;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\WithPagination;
|
use Livewire\WithPagination;
|
||||||
|
use Website\Hub\Concerns\HasRateLimiting;
|
||||||
|
|
||||||
#[Title('Waitlist')]
|
#[Title('Waitlist')]
|
||||||
class WaitlistManager extends Component
|
class WaitlistManager extends Component
|
||||||
{
|
{
|
||||||
|
use HasRateLimiting;
|
||||||
use WithPagination;
|
use WithPagination;
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
|
|
@ -68,19 +70,21 @@ class WaitlistManager extends Component
|
||||||
*/
|
*/
|
||||||
public function sendInvite(int $id): void
|
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()) {
|
if ($entry->isInvited()) {
|
||||||
session()->flash('error', 'This person has already been invited.');
|
session()->flash('error', 'This person has already been invited.');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$entry->generateInviteCode();
|
$entry->generateInviteCode();
|
||||||
$entry->notify(new WaitlistInviteNotification($entry));
|
$entry->notify(new WaitlistInviteNotification($entry));
|
||||||
|
|
||||||
session()->flash('message', "Invite sent to {$entry->email}");
|
session()->flash('message', "Invite sent to {$entry->email}");
|
||||||
$this->refreshStats();
|
$this->refreshStats();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -88,28 +92,30 @@ class WaitlistManager extends Component
|
||||||
*/
|
*/
|
||||||
public function sendBulkInvites(): void
|
public function sendBulkInvites(): void
|
||||||
{
|
{
|
||||||
$entries = WaitlistEntry::whereIn('id', $this->selected)
|
$this->rateLimit('admin-mutation', 10, function () {
|
||||||
->whereNull('invited_at')
|
$entries = WaitlistEntry::whereIn('id', $this->selected)
|
||||||
->get();
|
->whereNull('invited_at')
|
||||||
|
->get();
|
||||||
|
|
||||||
if ($entries->isEmpty()) {
|
if ($entries->isEmpty()) {
|
||||||
session()->flash('error', 'No pending entries selected.');
|
session()->flash('error', 'No pending entries selected.');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$count = 0;
|
$count = 0;
|
||||||
foreach ($entries as $entry) {
|
foreach ($entries as $entry) {
|
||||||
$entry->generateInviteCode();
|
$entry->generateInviteCode();
|
||||||
$entry->notify(new WaitlistInviteNotification($entry));
|
$entry->notify(new WaitlistInviteNotification($entry));
|
||||||
$count++;
|
$count++;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->selected = [];
|
$this->selected = [];
|
||||||
$this->selectAll = false;
|
$this->selectAll = false;
|
||||||
|
|
||||||
session()->flash('message', "Sent {$count} invite(s) successfully.");
|
session()->flash('message', "Sent {$count} invite(s) successfully.");
|
||||||
$this->refreshStats();
|
$this->refreshStats();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -141,18 +147,20 @@ class WaitlistManager extends Component
|
||||||
*/
|
*/
|
||||||
public function delete(int $id): void
|
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()) {
|
if ($entry->hasConverted()) {
|
||||||
session()->flash('error', 'Cannot delete entries that have converted to users.');
|
session()->flash('error', 'Cannot delete entries that have converted to users.');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$entry->delete();
|
$entry->delete();
|
||||||
|
|
||||||
session()->flash('message', 'Entry deleted.');
|
session()->flash('message', 'Entry deleted.');
|
||||||
$this->refreshStats();
|
$this->refreshStats();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -171,30 +179,32 @@ class WaitlistManager extends Component
|
||||||
*/
|
*/
|
||||||
public function export()
|
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) {
|
foreach ($entries as $entry) {
|
||||||
$status = $entry->hasConverted() ? 'Converted' : ($entry->isInvited() ? 'Invited' : 'Pending');
|
$status = $entry->hasConverted() ? 'Converted' : ($entry->isInvited() ? 'Invited' : 'Pending');
|
||||||
$csv .= sprintf(
|
$csv .= sprintf(
|
||||||
"%s,%s,%s,%s,%s,%s,%s,%s\n",
|
"%s,%s,%s,%s,%s,%s,%s,%s\n",
|
||||||
$entry->email,
|
$entry->email,
|
||||||
$entry->name ?? '',
|
$entry->name ?? '',
|
||||||
$entry->interest ?? '',
|
$entry->interest ?? '',
|
||||||
$entry->source ?? '',
|
$entry->source ?? '',
|
||||||
$status,
|
$status,
|
||||||
$entry->created_at->format('Y-m-d'),
|
$entry->created_at->format('Y-m-d'),
|
||||||
$entry->invited_at?->format('Y-m-d') ?? '',
|
$entry->invited_at?->format('Y-m-d') ?? '',
|
||||||
$entry->registered_at?->format('Y-m-d') ?? ''
|
$entry->registered_at?->format('Y-m-d') ?? ''
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->streamDownload(function () use ($csv) {
|
return response()->streamDownload(function () use ($csv) {
|
||||||
echo $csv;
|
echo $csv;
|
||||||
}, 'waitlist-export-'.now()->format('Y-m-d').'.csv', [
|
}, 'waitlist-export-'.now()->format('Y-m-d').'.csv', [
|
||||||
'Content-Type' => 'text/csv',
|
'Content-Type' => 'text/csv',
|
||||||
]);
|
]);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function refreshStats(): void
|
protected function refreshStats(): void
|
||||||
|
|
|
||||||
299
tests/Feature/Models/MetadataValidationTest.php
Normal file
299
tests/Feature/Models/MetadataValidationTest.php
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Core\Mod\Hub\Models\HoneypotHit;
|
||||||
|
use Core\Mod\Hub\Models\Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for JSON metadata field validation on Service and HoneypotHit models.
|
||||||
|
*
|
||||||
|
* Ensures size limits, key count limits, and key format validation
|
||||||
|
* are enforced to prevent mass assignment of arbitrary data.
|
||||||
|
*/
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
if (! \Illuminate\Support\Facades\Schema::hasTable('platform_services')) {
|
||||||
|
\Illuminate\Support\Facades\Schema::create('platform_services', function ($table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('code')->unique();
|
||||||
|
$table->string('module')->nullable();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('tagline')->nullable();
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
$table->string('icon')->nullable();
|
||||||
|
$table->string('color')->nullable();
|
||||||
|
$table->string('marketing_domain')->nullable();
|
||||||
|
$table->string('website_class')->nullable();
|
||||||
|
$table->string('marketing_url')->nullable();
|
||||||
|
$table->string('docs_url')->nullable();
|
||||||
|
$table->boolean('is_enabled')->default(true);
|
||||||
|
$table->boolean('is_public')->default(true);
|
||||||
|
$table->boolean('is_featured')->default(false);
|
||||||
|
$table->string('entitlement_code')->nullable();
|
||||||
|
$table->integer('sort_order')->default(50);
|
||||||
|
$table->json('metadata')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! \Illuminate\Support\Facades\Schema::hasTable('honeypot_hits')) {
|
||||||
|
\Illuminate\Support\Facades\Schema::create('honeypot_hits', function ($table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('ip_address', 45);
|
||||||
|
$table->string('user_agent', 1000)->nullable();
|
||||||
|
$table->string('referer', 2000)->nullable();
|
||||||
|
$table->string('path', 255);
|
||||||
|
$table->string('method', 10);
|
||||||
|
$table->json('headers')->nullable();
|
||||||
|
$table->string('country', 2)->nullable();
|
||||||
|
$table->string('city', 100)->nullable();
|
||||||
|
$table->boolean('is_bot')->default(false);
|
||||||
|
$table->string('bot_name', 100)->nullable();
|
||||||
|
$table->string('severity', 20)->default('warning');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index('ip_address');
|
||||||
|
$table->index('created_at');
|
||||||
|
$table->index('is_bot');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(function () {
|
||||||
|
Service::query()->delete();
|
||||||
|
HoneypotHit::query()->delete();
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Service Metadata Validation
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe('Service metadata validation', function () {
|
||||||
|
describe('setMetadataAttribute mutator', function () {
|
||||||
|
it('accepts valid metadata arrays', function () {
|
||||||
|
$service = new Service();
|
||||||
|
$service->metadata = ['key' => 'value', 'count' => 42];
|
||||||
|
|
||||||
|
expect($service->getAttributes()['metadata'])->toBe('{"key":"value","count":42}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts null metadata', function () {
|
||||||
|
$service = new Service();
|
||||||
|
$service->metadata = null;
|
||||||
|
|
||||||
|
expect($service->getAttributes()['metadata'])->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts valid JSON strings', function () {
|
||||||
|
$service = new Service();
|
||||||
|
$service->metadata = '{"key":"value"}';
|
||||||
|
|
||||||
|
expect($service->getAttributes()['metadata'])->toBe('{"key":"value"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid JSON strings', function () {
|
||||||
|
$service = new Service();
|
||||||
|
|
||||||
|
expect(fn () => $service->metadata = '{invalid json}')
|
||||||
|
->toThrow(InvalidArgumentException::class, 'Metadata must be valid JSON');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-array non-string values', function () {
|
||||||
|
$service = new Service();
|
||||||
|
|
||||||
|
expect(fn () => $service->metadata = 12345)
|
||||||
|
->toThrow(InvalidArgumentException::class, 'Metadata must be an array or null');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects metadata exceeding maximum key count', function () {
|
||||||
|
$service = new Service();
|
||||||
|
$data = [];
|
||||||
|
for ($i = 0; $i <= Service::METADATA_MAX_KEYS; $i++) {
|
||||||
|
$data["key_{$i}"] = 'value';
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(fn () => $service->metadata = $data)
|
||||||
|
->toThrow(InvalidArgumentException::class, 'Metadata exceeds maximum of');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts metadata at the maximum key count', function () {
|
||||||
|
$service = new Service();
|
||||||
|
$data = [];
|
||||||
|
for ($i = 0; $i < Service::METADATA_MAX_KEYS; $i++) {
|
||||||
|
$data["key_{$i}"] = 'v';
|
||||||
|
}
|
||||||
|
|
||||||
|
$service->metadata = $data;
|
||||||
|
|
||||||
|
expect(json_decode($service->getAttributes()['metadata'], true))
|
||||||
|
->toHaveCount(Service::METADATA_MAX_KEYS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects metadata exceeding maximum size', function () {
|
||||||
|
$service = new Service();
|
||||||
|
// Create a payload that exceeds 64KB
|
||||||
|
$data = ['large' => str_repeat('x', Service::METADATA_MAX_SIZE)];
|
||||||
|
|
||||||
|
expect(fn () => $service->metadata = $data)
|
||||||
|
->toThrow(InvalidArgumentException::class, 'Metadata exceeds maximum size');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists valid metadata to database', function () {
|
||||||
|
$service = Service::create([
|
||||||
|
'code' => 'test-service',
|
||||||
|
'name' => 'Test Service',
|
||||||
|
'metadata' => ['version' => '1.0', 'features' => ['a', 'b']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$fresh = Service::find($service->id);
|
||||||
|
expect($fresh->metadata)->toBe(['version' => '1.0', 'features' => ['a', 'b']]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setMeta key validation', function () {
|
||||||
|
it('accepts valid alphanumeric keys', function () {
|
||||||
|
$service = new Service();
|
||||||
|
$service->metadata = [];
|
||||||
|
|
||||||
|
$service->setMeta('valid_key', 'value');
|
||||||
|
expect($service->metadata['valid_key'])->toBe('value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts keys with hyphens', function () {
|
||||||
|
$service = new Service();
|
||||||
|
$service->metadata = [];
|
||||||
|
|
||||||
|
$service->setMeta('my-key', 'value');
|
||||||
|
expect($service->metadata['my-key'])->toBe('value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty keys', function () {
|
||||||
|
$service = new Service();
|
||||||
|
$service->metadata = [];
|
||||||
|
|
||||||
|
expect(fn () => $service->setMeta('', 'value'))
|
||||||
|
->toThrow(InvalidArgumentException::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects keys with special characters', function () {
|
||||||
|
$service = new Service();
|
||||||
|
$service->metadata = [];
|
||||||
|
|
||||||
|
expect(fn () => $service->setMeta('key.with.dots', 'value'))
|
||||||
|
->toThrow(InvalidArgumentException::class);
|
||||||
|
|
||||||
|
expect(fn () => $service->setMeta('key with spaces', 'value'))
|
||||||
|
->toThrow(InvalidArgumentException::class);
|
||||||
|
|
||||||
|
expect(fn () => $service->setMeta('key/path', 'value'))
|
||||||
|
->toThrow(InvalidArgumentException::class);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// HoneypotHit Headers Validation
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe('HoneypotHit headers validation', function () {
|
||||||
|
describe('setHeadersAttribute mutator', function () {
|
||||||
|
it('accepts valid header arrays', function () {
|
||||||
|
$hit = new HoneypotHit();
|
||||||
|
$hit->headers = ['host' => ['example.com'], 'accept' => ['text/html']];
|
||||||
|
|
||||||
|
$decoded = json_decode($hit->getAttributes()['headers'], true);
|
||||||
|
expect($decoded)->toHaveKey('host');
|
||||||
|
expect($decoded)->toHaveKey('accept');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts null headers', function () {
|
||||||
|
$hit = new HoneypotHit();
|
||||||
|
$hit->headers = null;
|
||||||
|
|
||||||
|
expect($hit->getAttributes()['headers'])->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('truncates headers exceeding count limit', function () {
|
||||||
|
$hit = new HoneypotHit();
|
||||||
|
$headers = [];
|
||||||
|
for ($i = 0; $i < HoneypotHit::HEADERS_MAX_COUNT + 20; $i++) {
|
||||||
|
$headers["x-header-{$i}"] = ["value-{$i}"];
|
||||||
|
}
|
||||||
|
|
||||||
|
$hit->headers = $headers;
|
||||||
|
|
||||||
|
$decoded = json_decode($hit->getAttributes()['headers'], true);
|
||||||
|
expect(count($decoded))->toBeLessThanOrEqual(HoneypotHit::HEADERS_MAX_COUNT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps headers at the exact limit', function () {
|
||||||
|
$hit = new HoneypotHit();
|
||||||
|
$headers = [];
|
||||||
|
for ($i = 0; $i < HoneypotHit::HEADERS_MAX_COUNT; $i++) {
|
||||||
|
$headers["h{$i}"] = ['v'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$hit->headers = $headers;
|
||||||
|
|
||||||
|
$decoded = json_decode($hit->getAttributes()['headers'], true);
|
||||||
|
expect(count($decoded))->toBe(HoneypotHit::HEADERS_MAX_COUNT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('truncates headers exceeding size limit', function () {
|
||||||
|
$hit = new HoneypotHit();
|
||||||
|
// Create headers with large values that exceed 16KB
|
||||||
|
$headers = [];
|
||||||
|
for ($i = 0; $i < 10; $i++) {
|
||||||
|
$headers["x-large-{$i}"] = [str_repeat('x', 2000)];
|
||||||
|
}
|
||||||
|
|
||||||
|
$hit->headers = $headers;
|
||||||
|
|
||||||
|
$json = $hit->getAttributes()['headers'];
|
||||||
|
expect(strlen($json))->toBeLessThanOrEqual(HoneypotHit::HEADERS_MAX_SIZE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles invalid JSON string gracefully', function () {
|
||||||
|
$hit = new HoneypotHit();
|
||||||
|
$hit->headers = '{not valid json}';
|
||||||
|
|
||||||
|
expect($hit->getAttributes()['headers'])->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles non-array non-string values gracefully', function () {
|
||||||
|
$hit = new HoneypotHit();
|
||||||
|
$hit->headers = 12345;
|
||||||
|
|
||||||
|
expect($hit->getAttributes()['headers'])->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts valid JSON strings', function () {
|
||||||
|
$hit = new HoneypotHit();
|
||||||
|
$hit->headers = '{"host":["example.com"]}';
|
||||||
|
|
||||||
|
$decoded = json_decode($hit->getAttributes()['headers'], true);
|
||||||
|
expect($decoded)->toHaveKey('host');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists valid headers to database', function () {
|
||||||
|
$hit = HoneypotHit::create([
|
||||||
|
'ip_address' => '192.168.1.1',
|
||||||
|
'path' => '/teapot',
|
||||||
|
'method' => 'GET',
|
||||||
|
'headers' => ['host' => ['example.com'], 'accept' => ['*/*']],
|
||||||
|
'severity' => 'warning',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$fresh = HoneypotHit::find($hit->id);
|
||||||
|
expect($fresh->headers)->toBe(['host' => ['example.com'], 'accept' => ['*/*']]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
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