Compare commits
No commits in common. "main" and "security/improve-teapot-sanitization" have entirely different histories.
main
...
security/i
9 changed files with 291 additions and 1108 deletions
|
|
@ -91,7 +91,7 @@ class TeapotController
|
|||
}
|
||||
|
||||
/**
|
||||
* Remove sensitive headers and enforce size limits before storing.
|
||||
* Whitelist headers useful for bot detection before storing.
|
||||
*/
|
||||
protected function sanitizeHeaders(array $headers): array
|
||||
{
|
||||
|
|
@ -109,14 +109,7 @@ class TeapotController
|
|||
'x-client-ip',
|
||||
];
|
||||
|
||||
$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;
|
||||
return array_intersect_key($headers, array_flip($allowed));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -27,61 +27,6 @@ class HoneypotHit extends Model
|
|||
'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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ namespace Core\Mod\Hub\Models;
|
|||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class Service extends Model
|
||||
{
|
||||
|
|
@ -122,55 +121,6 @@ class Service extends Model
|
|||
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.
|
||||
*/
|
||||
|
|
@ -189,17 +139,9 @@ class Service extends Model
|
|||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
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[$key] = $value;
|
||||
$this->metadata = $metadata;
|
||||
|
|
|
|||
|
|
@ -1,72 +0,0 @@
|
|||
<?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,12 +14,9 @@ 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
|
||||
|
|
@ -83,31 +80,27 @@ class PlatformUser extends Component
|
|||
|
||||
public function saveTier(): void
|
||||
{
|
||||
$this->rateLimit('admin-tier-change', 10, function () {
|
||||
$this->user->tier = UserTier::from($this->editingTier);
|
||||
$this->user->save();
|
||||
$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
|
||||
{
|
||||
$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;
|
||||
}
|
||||
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
|
||||
|
|
@ -130,23 +123,21 @@ class PlatformUser extends Component
|
|||
*/
|
||||
public function exportUserData()
|
||||
{
|
||||
return $this->rateLimit('admin-export', 5, function () {
|
||||
$data = $this->collectUserData();
|
||||
$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',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -249,34 +240,32 @@ class PlatformUser extends Component
|
|||
*/
|
||||
public function scheduleDelete(): void
|
||||
{
|
||||
$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';
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -351,52 +340,50 @@ class PlatformUser extends Component
|
|||
*/
|
||||
public function anonymizeUser(): void
|
||||
{
|
||||
$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';
|
||||
if ($this->user->isHades() && $this->user->id === auth()->id()) {
|
||||
$this->actionMessage = 'You cannot anonymize your own account.';
|
||||
$this->actionType = 'error';
|
||||
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$originalEmail = $this->user->email;
|
||||
$anonymizedId = 'anon_'.$this->user->id.'_'.now()->timestamp;
|
||||
$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,
|
||||
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,
|
||||
]);
|
||||
|
||||
$this->user->refresh();
|
||||
$this->editingTier = $this->user->tier?->value ?? 'free';
|
||||
$this->editingVerified = false;
|
||||
// Remove from all workspaces
|
||||
if (method_exists($this->user, 'hostWorkspaces')) {
|
||||
$this->user->hostWorkspaces()->detach();
|
||||
}
|
||||
|
||||
$this->actionMessage = 'User data has been anonymized.';
|
||||
$this->actionType = 'success';
|
||||
// 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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -461,35 +448,33 @@ class PlatformUser extends Component
|
|||
*/
|
||||
public function provisionPackage(): void
|
||||
{
|
||||
$this->rateLimit('admin-entitlement', 10, function () {
|
||||
if (! $this->selectedWorkspaceId || ! $this->selectedPackageCode) {
|
||||
$this->actionMessage = 'Please select a workspace and package.';
|
||||
$this->actionType = 'warning';
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -497,36 +482,34 @@ class PlatformUser extends Component
|
|||
*/
|
||||
public function revokePackage(int $workspaceId, string $packageCode): void
|
||||
{
|
||||
$this->rateLimit('admin-entitlement', 10, function () use ($workspaceId, $packageCode) {
|
||||
$workspace = Workspace::findOrFail($workspaceId);
|
||||
$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
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
|
@ -603,70 +586,68 @@ class PlatformUser extends Component
|
|||
*/
|
||||
public function provisionEntitlement(): void
|
||||
{
|
||||
$this->rateLimit('admin-entitlement', 10, function () {
|
||||
if (! $this->entitlementWorkspaceId || ! $this->entitlementFeatureCode) {
|
||||
$this->actionMessage = 'Please select a workspace and feature.';
|
||||
$this->actionType = 'warning';
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -674,36 +655,34 @@ class PlatformUser extends Component
|
|||
*/
|
||||
public function removeBoost(int $boostId): void
|
||||
{
|
||||
$this->rateLimit('admin-entitlement', 10, function () use ($boostId) {
|
||||
$boost = Boost::findOrFail($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,12 +15,9 @@ 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';
|
||||
|
|
@ -111,12 +108,6 @@ 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())
|
||||
|
|
@ -128,21 +119,19 @@ class Settings extends Component
|
|||
|
||||
public function updateProfile(): void
|
||||
{
|
||||
$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()],
|
||||
]);
|
||||
$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
|
||||
|
|
@ -174,24 +163,22 @@ class Settings extends Component
|
|||
|
||||
public function updatePassword(): void
|
||||
{
|
||||
$this->rateLimit('password-change', 5, function () {
|
||||
$this->validate([
|
||||
'current_password' => ['required', 'current_password'],
|
||||
'new_password' => ['required', 'confirmed', Password::defaults()],
|
||||
]);
|
||||
$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
|
||||
|
|
@ -226,22 +213,20 @@ class Settings extends Component
|
|||
|
||||
public function requestAccountDeletion(): void
|
||||
{
|
||||
$this->rateLimit('account-deletion', 3, function () {
|
||||
// Get the base user model for the app
|
||||
$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
|
||||
$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,12 +10,10 @@ 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
|
||||
|
|
@ -70,21 +68,19 @@ class WaitlistManager extends Component
|
|||
*/
|
||||
public function sendInvite(int $id): void
|
||||
{
|
||||
$this->rateLimit('admin-mutation', 10, function () use ($id) {
|
||||
$entry = WaitlistEntry::findOrFail($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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -92,30 +88,28 @@ class WaitlistManager extends Component
|
|||
*/
|
||||
public function sendBulkInvites(): void
|
||||
{
|
||||
$this->rateLimit('admin-mutation', 10, function () {
|
||||
$entries = WaitlistEntry::whereIn('id', $this->selected)
|
||||
->whereNull('invited_at')
|
||||
->get();
|
||||
$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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -147,20 +141,18 @@ class WaitlistManager extends Component
|
|||
*/
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$this->rateLimit('admin-mutation', 10, function () use ($id) {
|
||||
$entry = WaitlistEntry::findOrFail($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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -179,32 +171,30 @@ class WaitlistManager extends Component
|
|||
*/
|
||||
public function export()
|
||||
{
|
||||
return $this->rateLimit('admin-export', 5, function () {
|
||||
$entries = $this->getFilteredQuery()->get();
|
||||
$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
|
||||
|
|
|
|||
|
|
@ -1,299 +0,0 @@
|
|||
<?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' => ['*/*']]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,280 +0,0 @@
|
|||
<?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