From 383c8f46130caf9f2ef4397a5607bfaf4e214e34 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 13:05:58 +0800 Subject: [PATCH 01/51] fix(files): make uploads work with a private S3 media bucket - Drop the 'public' visibility from Utils::urlToStorefrontFile: a bucket with BucketOwnerEnforced rejects any PUT carrying an ACL, so put() returned false. - Add File::signStoredUrl()/s3KeyFromUrl(): turn absolute URLs stored as strings (legacy unsigned bucket URLs, expired signed URLs) back into a key and re-sign. - Re-sign template builder image src at render time. - Cache signed URLs for 60 of their 120 minutes so every URL handed out has at least an hour left; cut Extension icon_url cache from 24h to 30m. --- src/Models/Extension.php | 3 +- src/Models/File.php | 102 +++++++++++++++++++++---- src/Services/TemplateRenderService.php | 4 +- src/Support/Utils.php | 5 +- tests/Unit/Models/FileModelTest.php | 47 ++++++++++++ 5 files changed, 142 insertions(+), 19 deletions(-) diff --git a/src/Models/Extension.php b/src/Models/Extension.php index aab27aec..86b9b7f2 100644 --- a/src/Models/Extension.php +++ b/src/Models/Extension.php @@ -187,7 +187,8 @@ public function getAuthorNameAttribute() */ public function getIconUrlAttribute() { - return static::attributeFromCache($this, 'file.url', 'https://s3.ap-southeast-1.amazonaws.com/flb-assets/static/no-avatar.png'); + // short TTL: file.url is a signed URL that expires, a day-long cache would serve dead links + return static::attributeFromCache($this, 'file.url', 'https://s3.ap-southeast-1.amazonaws.com/flb-assets/static/no-avatar.png', 30 * 60); } /** diff --git a/src/Models/File.php b/src/Models/File.php index 05e3cc59..1eaa37e3 100644 --- a/src/Models/File.php +++ b/src/Models/File.php @@ -147,30 +147,102 @@ public function getUrlAttribute() /** @var Storage $filesystem */ $filesystem = $this->getFilesystem(); - $cacheKey = "file_url_{$this->uuid}"; - $bufferTime = 5; // Buffer time in minutes + if ($disk === 's3' || $disk === 'gcs') { + return static::cachedTemporaryUrl($filesystem, $this->path, "file_url_{$this->uuid}"); + } + + $url = $filesystem->url($this->path); + + if ($disk === 'local') { + return asset($url, !app()->environment(['development', 'local'])); + } + + return $url; + } + + /** + * Generate a signed URL for an object, cached for slightly less than its lifetime. + */ + protected static function cachedTemporaryUrl($filesystem, string $path, string $cacheKey): string + { + // Cache for half the signature's lifetime, so every URL handed out has at least an hour left. + // Callers (browser tabs, short-lived caches) hold the string after we return it. + $bufferTime = 60; // Buffer time in minutes $urlExpiration = 120; // URL expiration time in minutes (2 hours) - if ($disk === 's3' || $disk === 'gcs') { - // Check if the URL is already cached - if (Cache::has($cacheKey)) { - return Cache::get($cacheKey); - } + // Check if the URL is already cached + if (Cache::has($cacheKey)) { + return Cache::get($cacheKey); + } + + // Generate a new temporary URL + $url = $filesystem->temporaryUrl($path, now()->addMinutes($urlExpiration)); + + // Cache the URL with a reduced expiration time for buffer + Cache::put($cacheKey, $url, now()->addMinutes($urlExpiration - $bufferTime)); + + return $url; + } - // Generate a new temporary URL - $url = $filesystem->temporaryUrl($this->path, now()->addMinutes($urlExpiration)); + /** + * Re-sign an absolute URL that was stored as a string and points into the configured S3 bucket. + * + * Some columns (e.g. legacy `avatar_url` values, cart item image URLs) hold a URL rather than a + * File reference. Plain bucket URLs only work while the bucket is publicly readable, and stored + * signed URLs stop working once their signature expires, so both are turned back into an object + * key and signed afresh. Anything else (other hosts, flb-assets, relative paths, UUIDs) is + * returned unchanged. + */ + public static function signStoredUrl(?string $url): ?string + { + $key = static::s3KeyFromUrl($url); + if ($key === null) { + return $url; + } - // Cache the URL with a reduced expiration time for buffer - Cache::put($cacheKey, $url, now()->addMinutes($urlExpiration - $bufferTime)); + return static::cachedTemporaryUrl(Storage::disk('s3'), $key, 'file_url_key_' . sha1($key)); + } + + /** + * Extract the object key from a URL that points into the configured S3 bucket, or null. + * + * Recognises virtual-hosted (`bucket.s3.region.amazonaws.com/key`, `bucket.s3-region...`), + * path-style (`s3.region.amazonaws.com/bucket/key`) and the disk's configured `url` (AWS_URL). + */ + public static function s3KeyFromUrl(?string $url): ?string + { + if (!is_string($url) || !preg_match('#^https?://#i', $url)) { + return null; + } + + $bucket = config('filesystems.disks.s3.bucket'); + if (!is_string($bucket) || $bucket === '') { + return null; + } + + $key = null; + $withoutQs = preg_split('/[?#]/', $url, 2)[0]; + $configured = config('filesystems.disks.s3.url'); + + if (is_string($configured) && $configured !== '' && Str::startsWith($withoutQs, rtrim($configured, '/') . '/')) { + $key = Str::after($withoutQs, rtrim($configured, '/') . '/'); } else { - $url = $filesystem->url($this->path); + $host = strtolower((string) parse_url($withoutQs, PHP_URL_HOST)); + $path = ltrim((string) parse_url($withoutQs, PHP_URL_PATH), '/'); + $quoted = preg_quote(strtolower($bucket), '#'); + + if (preg_match('#^' . $quoted . '\.s3([.-][a-z0-9-]+)?\.amazonaws\.com$#', $host)) { + $key = $path; + } elseif (preg_match('#^s3([.-][a-z0-9-]+)?\.amazonaws\.com$#', $host) && Str::startsWith($path, $bucket . '/')) { + $key = Str::after($path, $bucket . '/'); + } } - if ($disk === 'local') { - return asset($url, !app()->environment(['development', 'local'])); + if ($key === null || $key === '') { + return null; } - return $url; + return rawurldecode($key); } /** diff --git a/src/Services/TemplateRenderService.php b/src/Services/TemplateRenderService.php index c0583b4f..e6348435 100644 --- a/src/Services/TemplateRenderService.php +++ b/src/Services/TemplateRenderService.php @@ -2,6 +2,7 @@ namespace Fleetbase\Services; +use Fleetbase\Models\File; use Fleetbase\Models\Template; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Carbon; @@ -297,7 +298,8 @@ protected function renderElement(array $element): string return "
{$content}
\n"; case 'image': - $src = data_get($element, 'src', ''); + // the builder stores the upload's URL; re-sign it so old (expired or unsigned) bucket URLs still render + $src = File::signStoredUrl((string) data_get($element, 'src', '')); return "\"\"\n"; diff --git a/src/Support/Utils.php b/src/Support/Utils.php index a26b916f..473f9269 100644 --- a/src/Support/Utils.php +++ b/src/Support/Utils.php @@ -1791,8 +1791,9 @@ public static function urlToStorefrontFile($url, $type = 'source', ?Model $owner $bucketPath = 'uploads/storefront/' . $owner->uuid . '/' . Str::slug($type) . '/' . $fileName; $pathInfo = pathinfo($bucketPath); - // upload to bucket - Storage::disk('s3')->put($bucketPath, $contents, 'public'); + // upload to bucket. No 'public' visibility: the media bucket is private and enforces + // bucket-owner object ownership, which rejects any request carrying an ACL. + Storage::disk('s3')->put($bucketPath, $contents); $fileInfo = [ 'company_uuid' => $owner->company_uuid ?? null, diff --git a/tests/Unit/Models/FileModelTest.php b/tests/Unit/Models/FileModelTest.php index 6a89779c..1dfe327f 100644 --- a/tests/Unit/Models/FileModelTest.php +++ b/tests/Unit/Models/FileModelTest.php @@ -343,6 +343,53 @@ function bind_file_model_filesystem(array $config = []): FileModelFilesystemFake expect($file->url)->toBe('https://cdn.example.test/cached-report.csv'); }); +it('extracts object keys only from urls that point into the configured s3 bucket', function () { + bind_file_model_filesystem([ + 'filesystems.disks.s3.bucket' => 'fleetbase-production-media', + 'filesystems.disks.s3.url' => 'https://media.example.test/assets', + ]); + + expect(File::s3KeyFromUrl('https://fleetbase-production-media.s3.amazonaws.com/uploads/a/photo.png'))->toBe('uploads/a/photo.png') + ->and(File::s3KeyFromUrl('https://fleetbase-production-media.s3.ap-southeast-1.amazonaws.com/uploads/a/photo.png?X-Amz-Signature=old'))->toBe('uploads/a/photo.png') + ->and(File::s3KeyFromUrl('https://fleetbase-production-media.s3-ap-southeast-1.amazonaws.com/custom-avatars/vehicles/c/My%20Van.png'))->toBe('custom-avatars/vehicles/c/My Van.png') + ->and(File::s3KeyFromUrl('https://s3.ap-southeast-1.amazonaws.com/fleetbase-production-media/uploads/b/logo.png'))->toBe('uploads/b/logo.png') + ->and(File::s3KeyFromUrl('https://media.example.test/assets/uploads/c/doc.pdf'))->toBe('uploads/c/doc.pdf') + // other buckets, other hosts and non-urls are left alone + ->and(File::s3KeyFromUrl('https://flb-assets.s3.ap-southeast-1.amazonaws.com/static/no-avatar.png'))->toBeNull() + ->and(File::s3KeyFromUrl('https://s3.ap-southeast-1.amazonaws.com/flb-assets/static/no-avatar.png'))->toBeNull() + ->and(File::s3KeyFromUrl('https://evil.example.test/fleetbase-production-media.s3.amazonaws.com/x.png'))->toBeNull() + ->and(File::s3KeyFromUrl('https://fleetbase-production-media.s3.amazonaws.com.evil.test/x.png'))->toBeNull() + ->and(File::s3KeyFromUrl('https://fleetbase-production-media.s3.amazonaws.com/'))->toBeNull() + ->and(File::s3KeyFromUrl('5f1c2a10-0000-4000-8000-000000000000'))->toBeNull() + ->and(File::s3KeyFromUrl(null))->toBeNull(); + + // config is shared across tests: drop the url override here as well + bind_file_model_filesystem(['filesystems.disks.s3.bucket' => null, 'filesystems.disks.s3.url' => null]); + + expect(File::s3KeyFromUrl('https://fleetbase-production-media.s3.amazonaws.com/uploads/a/photo.png'))->toBeNull(); +}); + +it('re-signs stored bucket urls and leaves every other value unchanged', function () { + $filesystem = bind_file_model_filesystem([ + 'filesystems.disks.s3.bucket' => 'fleetbase-production-media', + ]); + + $stored = 'https://fleetbase-production-media.s3.ap-southeast-1.amazonaws.com/custom-avatars/vehicles/c/van.png?X-Amz-Expires=7200&X-Amz-Signature=expired'; + + expect(File::signStoredUrl($stored))->toBe('https://s3.example.test/custom-avatars/vehicles/c/van.png?temporary=1') + ->and($filesystem->disk('s3')->temporaryUrls)->toHaveKey('custom-avatars/vehicles/c/van.png') + ->and(File::signStoredUrl('https://flb-assets.s3.ap-southeast-1.amazonaws.com/static/vehicle-icons/mini_bus.svg')) + ->toBe('https://flb-assets.s3.ap-southeast-1.amazonaws.com/static/vehicle-icons/mini_bus.svg') + ->and(File::signStoredUrl(null))->toBeNull() + ->and(File::signStoredUrl(''))->toBe(''); + + // the signed url is cached per object key, like File::url + $filesystem->disk('s3')->temporaryUrls = []; + + expect(File::signStoredUrl($stored))->toBe('https://s3.example.test/custom-avatars/vehicles/c/van.png?temporary=1') + ->and($filesystem->disk('s3')->temporaryUrls)->toBe([]); +}); + it('assigns uploaders subjects and file types through model mutators', function () { bind_test_container(); From e6ef26ddc3a95f14a4f810dba7e91eff960c5c75 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 13:12:55 +0800 Subject: [PATCH 02/51] feat(backups): settings-driven database backups that fail loudly Replace the db:backup command, which piped mysqldump through gzip without pipefail, swallowed upload errors and returned success on a failed dump. That is how production uploaded 20-byte empty dumps on 2026-09-24/25 (no mysqldump in the image) and then nothing at all while reporting DONE. - DatabaseBackupService streams the dump client's stdout into gzip in PHP (no shell pipeline), passes the password via MYSQL_PWD, and fails a run on a non-zero exit, a missing '-- Dump completed' marker, a dump under min_size_bytes, or an uploaded object whose size differs from the file. - Uploads go to any filesystem disk (bucket override for s3, key prefix); retention by age and/or count runs only after a fully successful run and always keeps each database's newest backup. - Every attempt is recorded in database_backups (status, size, duration, error, trigger); failures can email configured addresses. - Settings live in system.database-backups (env defaults in config/database-backups.php) and drive the schedule; disabled by default. - Admin endpoints under int/v1/database-backups: settings get/save/reset, recent runs, and a queued 'run now'. - db:backup exits non-zero on any failure; --force runs while disabled. --- config/database-backups.php | 81 ++ config/laravel-mysql-s3-backup.php | 63 -- ...6_000000_create_database_backups_table.php | 44 + src/Console/Commands/BackupDatabase.php | 65 ++ .../Commands/BackupDatabase/MysqlS3Backup.php | 186 ---- .../BackupDatabase/S3BackupTrimmer.php | 93 -- .../Internal/v1/DatabaseBackupController.php | 98 ++ src/Jobs/RunDatabaseBackup.php | 46 + src/Models/DatabaseBackup.php | 97 ++ src/Notifications/DatabaseBackupFailed.php | 69 ++ src/Providers/CoreServiceProvider.php | 7 +- .../DatabaseBackupException.php | 10 + .../DatabaseBackup/DatabaseBackupService.php | 364 +++++++ src/Support/DatabaseBackupSettings.php | 188 ++++ src/routes.php | 7 + .../Console/BackupDatabaseCommandsTest.php | 386 ------- tests/Unit/DatabaseBackupsTest.php | 938 ++++++++++++++++++ tests/Unit/RoutesContractTest.php | 21 + 18 files changed, 2033 insertions(+), 730 deletions(-) create mode 100644 config/database-backups.php delete mode 100644 config/laravel-mysql-s3-backup.php create mode 100644 migrations/2026_10_06_000000_create_database_backups_table.php create mode 100644 src/Console/Commands/BackupDatabase.php delete mode 100644 src/Console/Commands/BackupDatabase/MysqlS3Backup.php delete mode 100644 src/Console/Commands/BackupDatabase/S3BackupTrimmer.php create mode 100644 src/Http/Controllers/Internal/v1/DatabaseBackupController.php create mode 100644 src/Jobs/RunDatabaseBackup.php create mode 100644 src/Models/DatabaseBackup.php create mode 100644 src/Notifications/DatabaseBackupFailed.php create mode 100644 src/Services/DatabaseBackup/DatabaseBackupException.php create mode 100644 src/Services/DatabaseBackup/DatabaseBackupService.php create mode 100644 src/Support/DatabaseBackupSettings.php delete mode 100644 tests/Unit/Console/BackupDatabaseCommandsTest.php create mode 100644 tests/Unit/DatabaseBackupsTest.php diff --git a/config/database-backups.php b/config/database-backups.php new file mode 100644 index 00000000..6d4fd674 --- /dev/null +++ b/config/database-backups.php @@ -0,0 +1,81 @@ + env('DB_BACKUP_ENABLED', false), + + /* + * hourly, every_six_hours, every_twelve_hours, daily or weekly. Times are UTC. + */ + 'frequency' => env('DB_BACKUP_FREQUENCY', 'daily'), + 'time' => env('DB_BACKUP_TIME', '00:00'), + 'day_of_week' => (int) env('DB_BACKUP_DAY_OF_WEEK', 0), + + /* + * Where dumps go: a disk from config/filesystems.php, an optional bucket override for + * s3 disks, and a key prefix. + */ + 'disk' => env('DB_BACKUP_DISK', 's3'), + 'bucket' => env('DB_BACKUP_BUCKET', 'fleetbase-db-backups'), + 'path' => env('DB_BACKUP_PATH', ''), + + /* + * The database connections to dump, by name. + */ + 'connections' => array_values(array_filter(array_map('trim', explode(',', (string) env('DB_BACKUP_CONNECTIONS', 'mysql,sandbox'))))), + + /* + * Retention, applied after each fully successful run. Null disables that limit. + */ + 'retention_days' => env('DB_BACKUP_RETENTION_DAYS', 30), + 'retention_count' => env('DB_BACKUP_RETENTION_COUNT'), + + /* + * A compressed dump smaller than this many bytes fails the run. A gzip of nothing is + * 20 bytes; even an empty schema dump compresses to several hundred. + */ + 'min_size_bytes' => (int) env('DB_BACKUP_MIN_SIZE_BYTES', 1024), + + /* + * Who hears about a failed run. + */ + 'notify_on_failure' => env('DB_BACKUP_NOTIFY_ON_FAILURE', false), + 'notify_emails' => array_values(array_filter(array_map('trim', explode(',', (string) env('DB_BACKUP_NOTIFY_EMAILS', ''))))), + + /* + * The dump client and the arguments it always gets. --single-transaction gives a + * consistent InnoDB snapshot without locking; --no-tablespaces avoids needing the + * PROCESS privilege, which managed databases such as RDS do not grant. + */ + 'dump_binary' => env('DB_BACKUP_DUMP_BINARY', 'mysqldump'), + 'dump_args' => [ + '--single-transaction', + '--quick', + '--routines', + '--triggers', + '--hex-blob', + '--no-tablespaces', + '--default-character-set=utf8mb4', + ], + 'extra_dump_args' => array_values(array_filter(explode(' ', (string) env('DB_BACKUP_EXTRA_DUMP_ARGS', '')))), + + /* + * Seconds a single database's dump may take. + */ + 'timeout' => (int) env('DB_BACKUP_TIMEOUT', 7200), + + /* + * Where dumps are written before upload. + */ + 'tmp_dir' => env('DB_BACKUP_TMP_DIR', sys_get_temp_dir()), +]; diff --git a/config/laravel-mysql-s3-backup.php b/config/laravel-mysql-s3-backup.php deleted file mode 100644 index 0170d661..00000000 --- a/config/laravel-mysql-s3-backup.php +++ /dev/null @@ -1,63 +0,0 @@ - 'latest', - 'bucket' => env('DB_BACKUP_BUCKET', 'fleetbase-db-backups'), - 'region' => env('AWS_DEFAULT_REGION', 'ap-southeast-1'), - 'endpoint' => env('AWS_ENDPOINT') -]; - -if (env('APP_ENV') === 'local' || env('APP_ENV') === 'development') { - $s3Config['key'] = env('AWS_ACCESS_KEY_ID'); - $s3Config['secret'] = env('AWS_SECRET_ACCESS_KEY'); -} - -return [ - /* - * Configure with your Amazon S3 credentials - * You should use an IAM user who only has PutObject access - * to a specified bucket - */ - 's3' => $s3Config, - - /* - * Want to add some custom mysqldump args? - */ - 'custom_mysqldump_args' => '--default-character-set=utf8mb4', - - /* - * Whether or not to gzip the .sql file - */ - 'gzip' => true, - - /* - * Time allowed to run backup - */ - 'sql_timout' => 7200, // 2 hours - - /* - * Backup filename - */ - 'filename' => str_replace([' ', '-'], '_', env('APP_ENV', 'local')) . '_%s_backup-%s.sql', - - /* - * Where to store the backup file locally - */ - 'backup_dir' => '/tmp', - - /* - * Do you want to keep a copy of it or delete it - * after it's been uploaded? - */ - 'keep_local_copy' => false, - - /* - * Do you want to keep a rolling number of - * backups on S3? How many days worth? - */ - 'rolling_backup_days' => 30, -]; diff --git a/migrations/2026_10_06_000000_create_database_backups_table.php b/migrations/2026_10_06_000000_create_database_backups_table.php new file mode 100644 index 00000000..a2e0f400 --- /dev/null +++ b/migrations/2026_10_06_000000_create_database_backups_table.php @@ -0,0 +1,44 @@ +uuid('uuid')->primary(); + $table->string('connection_name', 64); + $table->string('database', 128); + $table->string('status', 16)->index(); + $table->string('trigger', 16); + $table->string('disk', 64); + $table->string('path', 512)->nullable(); + $table->unsignedBigInteger('size_bytes')->nullable(); + $table->unsignedBigInteger('duration_ms')->nullable(); + $table->text('error')->nullable(); + $table->timestamp('started_at')->index(); + $table->timestamp('completed_at')->nullable(); + $table->timestamp('pruned_at')->nullable(); + $table->timestamps(); + + $table->index(['disk', 'path']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('database_backups'); + } +}; diff --git a/src/Console/Commands/BackupDatabase.php b/src/Console/Commands/BackupDatabase.php new file mode 100644 index 00000000..b0cacf32 --- /dev/null +++ b/src/Console/Commands/BackupDatabase.php @@ -0,0 +1,65 @@ +option('force')) { + $this->info('Database backups are disabled. Enable them under Admin → Database Backups, or pass --force.'); + + return self::SUCCESS; + } + + $trigger = in_array($this->option('trigger'), [DatabaseBackup::TRIGGER_SCHEDULED, DatabaseBackup::TRIGGER_MANUAL, DatabaseBackup::TRIGGER_CONSOLE], true) + ? $this->option('trigger') + : DatabaseBackup::TRIGGER_CONSOLE; + + try { + $records = $service->run($trigger, $this->option('connection') ?: null, $settings); + } catch (DatabaseBackupException $e) { + $this->error($e->getMessage()); + + return self::FAILURE; + } + + if (!$records) { + $this->error('No database connections are configured for backup.'); + + return self::FAILURE; + } + + $failed = 0; + foreach ($records as $record) { + if ($record->status === DatabaseBackup::STATUS_COMPLETED) { + $this->info(sprintf('Backed up %s to %s:%s (%d bytes, %d ms)', $record->database, $record->disk, $record->path, $record->size_bytes, $record->duration_ms)); + } else { + $failed++; + $this->error(sprintf('Backup of %s failed: %s', $record->database, $record->error)); + } + } + + return $failed ? self::FAILURE : self::SUCCESS; + } +} diff --git a/src/Console/Commands/BackupDatabase/MysqlS3Backup.php b/src/Console/Commands/BackupDatabase/MysqlS3Backup.php deleted file mode 100644 index ae926121..00000000 --- a/src/Console/Commands/BackupDatabase/MysqlS3Backup.php +++ /dev/null @@ -1,186 +0,0 @@ - %s', escapeshellarg($fileName)); - } else { - $cmd .= sprintf(' > %s', escapeshellarg($fileName)); - } - - if ($this->output->isVerbose()) { - $this->output->writeln('Running backup for database `' . $databaseName . '`'); - $this->output->writeln('Saving to ' . $fileName); - } - - if ($this->output->isDebug()) { - $this->output->writeln("Running command: {$cmd}"); - } - - $process = $this->makeProcess($cmd); - $process->setTimeout(config('laravel-mysql-s3-backup.sql_timout')); - $process->run(); - - if (!$process->isSuccessful()) { - $this->error($process->getErrorOutput()); - - if ($this->output->isVerbose()) { - $this->output->writeln( - sprintf( - 'Unable to dump database for %s with a file name of %s. Error: %s', - now()->toDateString(), - $fileName, - $process->getErrorOutput() - ) - ); - } - - return; - } - - if ($this->output->isVerbose()) { - $this->output->writeln("Backup saved to {$fileName}"); - } - - // Upload to S3 - $s3config = config('laravel-mysql-s3-backup.s3'); - $s3 = $this->makeS3Client($s3config); - - $bucket = config('laravel-mysql-s3-backup.s3.bucket'); - $key = basename($fileName); - - if ($folder = config('laravel-mysql-s3-backup.s3.folder')) { - $key = $folder . '/' . $key; - } - - if ($this->output->isVerbose()) { - $this->output->writeln(sprintf('Uploading %s to S3/%s', $key, $bucket)); - } - - $uploader = $this->makeMultipartUploader( - $s3, - $fileName, - [ - 'bucket' => $bucket, - 'key' => $key, - ] - ); - - try { - $uploader->upload(); - } catch (MultipartUploadException $e) { - if ($this->output->isVerbose()) { - $this->output->writeln( - sprintf( - 'Unable to upload "%s" backup to s3. Error: %s', - $fileName, - $e->getMessage() - ) - ); - } - } - - // Delete the local tmp file - if (!config('laravel-mysql-s3-backup.keep_local_copy')) { - if ($this->output->isVerbose()) { - $this->output->writeln("Deleting local backup file {$fileName}"); - } - - $this->deleteLocalFile($fileName); - } - - if ($this->output->isVerbose()) { - $this->output->writeln("Backup {$fileName} successfully uploaded to s3"); - } - - if (config('laravel-mysql-s3-backup.rolling_backup_days')) { - if ($this->output->isVerbose()) { - $this->output->writeln("Trimming {$bucket} have have only " . config('laravel-mysql-s3-backup.rolling_backup_days') . ' days of backups'); - } - - $this->makeBackupTrimmer(config('laravel-mysql-s3-backup.rolling_backup_days'), $bucket)->run(); - } - } - } - - protected function makeProcess(string $command) - { - return Process::fromShellCommandline($command); - } - - protected function makeS3Client(array $config) - { - return new S3Client($config); - } - - protected function makeMultipartUploader($s3, string $fileName, array $options) - { - return new MultipartUploader($s3, $fileName, $options); - } - - protected function makeBackupTrimmer($days, $bucket): S3BackupTrimmer - { - return S3BackupTrimmer::make($days, $bucket); - } - - protected function deleteLocalFile(string $fileName): void - { - unlink($fileName); - } -} diff --git a/src/Console/Commands/BackupDatabase/S3BackupTrimmer.php b/src/Console/Commands/BackupDatabase/S3BackupTrimmer.php deleted file mode 100644 index b929fa17..00000000 --- a/src/Console/Commands/BackupDatabase/S3BackupTrimmer.php +++ /dev/null @@ -1,93 +0,0 @@ -days = $days; - $this->bucket = $bucket; - $this->when = now()->subDays($this->days)->startOfDay(); - } - - public static function make($days, $bucket) - { - return new static($days, $bucket); - } - - public function run() - { - $s3config = config('laravel-mysql-s3-backup.s3'); - $s3 = $this->makeS3Client($s3config); - - with($s3->listObjects( - [ - 'Bucket' => $this->bucket, - ] - ), function ($response) { - return collect($response['Contents'] ?? []) - ->when( - !empty(config('laravel-mysql-s3-backup.s3.folder')), - function ($contents) { - return collect($contents)->reject( - function ($item) { - return !Str::startsWith($item['Key'], config('laravel-mysql-s3-backup.s3.folder') . '/'); - } - )->values(); - } - ) - ->transform( - function ($item) { - return $item['Key']; - } - ); - })->filter( - function ($filename) { - if (!empty(config('laravel-mysql-s3-backup.s3.folder'))) { - $filename = str_replace(config('laravel-mysql-s3-backup.s3.folder') . '/', '', $filename); - } - - // date is second to last part of filename - $parts = explode('-', $filename); - $index = count($parts) - 2; - $date = $parts[$index]; - - return Carbon::createFromFormat('Ymd', $date)->lt($this->when); - } - )->tap( - function ($filenames) use ($s3) { - if ($filenames->isNotEmpty()) { - $s3->deleteObjects( - [ - 'Bucket' => $this->bucket, - 'Delete' => [ - 'Objects' => $filenames->map( - function ($filename) { - return ['Key' => $filename]; - } - )->all(), - ], - ] - ); - } - } - ); - } - - protected function makeS3Client(array $config) - { - // Real AWS client construction is covered at the command seam with injected fakes. - // @codeCoverageIgnoreStart - return new S3Client($config); - // @codeCoverageIgnoreEnd - } -} diff --git a/src/Http/Controllers/Internal/v1/DatabaseBackupController.php b/src/Http/Controllers/Internal/v1/DatabaseBackupController.php new file mode 100644 index 00000000..2784d49a --- /dev/null +++ b/src/Http/Controllers/Internal/v1/DatabaseBackupController.php @@ -0,0 +1,98 @@ +json($this->settingsPayload(DatabaseBackupSettings::settings())); + } + + /** + * Save the administrator's settings; the scheduler picks them up from its next minute. + */ + public function saveSettings(AdminRequest $request): JsonResponse + { + $validated = $request->validate([ + 'enabled' => ['required', 'boolean'], + 'frequency' => ['required', Rule::in(DatabaseBackupSettings::FREQUENCIES)], + 'time' => ['required', 'regex:/^([01]\d|2[0-3]):[0-5]\d$/'], + 'day_of_week' => ['sometimes', 'integer', 'min:0', 'max:6'], + 'disk' => ['required', 'string', Rule::in(array_column(DatabaseBackupSettings::disks(), 'name'))], + 'bucket' => ['nullable', 'string', 'max:255'], + 'path' => ['nullable', 'string', 'max:255'], + 'connections' => ['required', 'array', 'min:1'], + 'connections.*' => ['string', Rule::in(DatabaseBackupSettings::connections())], + 'retention_days' => ['nullable', 'integer', 'min:1', 'max:3650'], + 'retention_count' => ['nullable', 'integer', 'min:1', 'max:10000'], + 'min_size_bytes' => ['sometimes', 'integer', 'min:0'], + 'notify_on_failure' => ['sometimes', 'boolean'], + 'notify_emails' => ['sometimes', 'array'], + 'notify_emails.*' => ['email'], + ]); + + return response()->json($this->settingsPayload(DatabaseBackupSettings::store($validated))); + } + + /** + * Discard the administrator's settings and fall back to the environment. + */ + public function resetSettings(AdminRequest $request): JsonResponse + { + return response()->json($this->settingsPayload(DatabaseBackupSettings::reset())); + } + + /** + * Recent runs, newest first. + */ + public function runs(AdminRequest $request): JsonResponse + { + $request->validate(['limit' => ['sometimes', 'integer', 'min:1', 'max:200']]); + + $runs = DatabaseBackup::orderByDesc('started_at') + ->limit((int) $request->input('limit', 25)) + ->get() + ->map(fn (DatabaseBackup $backup) => $backup->toAdminArray()) + ->values(); + + return response()->json(['runs' => $runs]); + } + + /** + * Queue a backup of the configured databases now, whether or not scheduling is enabled. + */ + public function run(AdminRequest $request): JsonResponse + { + RunDatabaseBackup::dispatch(DatabaseBackup::TRIGGER_MANUAL); + + return response()->json(['status' => 'queued'], 202); + } + + protected function settingsPayload(array $settings): array + { + $lastRun = DatabaseBackup::orderByDesc('started_at')->first(); + $lastSuccess = DatabaseBackup::where('status', DatabaseBackup::STATUS_COMPLETED)->orderByDesc('started_at')->first(); + + return [ + 'settings' => $settings, + 'defaults' => DatabaseBackupSettings::defaults(), + 'disks' => DatabaseBackupSettings::disks(), + 'connections' => DatabaseBackupSettings::connections(), + 'last_run' => $lastRun?->toAdminArray(), + 'last_success' => $lastSuccess?->toAdminArray(), + ]; + } +} diff --git a/src/Jobs/RunDatabaseBackup.php b/src/Jobs/RunDatabaseBackup.php new file mode 100644 index 00000000..da7ff503 --- /dev/null +++ b/src/Jobs/RunDatabaseBackup.php @@ -0,0 +1,46 @@ +run($this->trigger); + } catch (DatabaseBackupException $e) { + Log::warning('Requested database backup did not run', ['error' => $e->getMessage()]); + } + } +} diff --git a/src/Models/DatabaseBackup.php b/src/Models/DatabaseBackup.php new file mode 100644 index 00000000..5b3928b8 --- /dev/null +++ b/src/Models/DatabaseBackup.php @@ -0,0 +1,97 @@ + 'integer', + 'duration_ms' => 'integer', + 'started_at' => 'datetime', + 'completed_at' => 'datetime', + 'pruned_at' => 'datetime', + ]; + + public function getConnectionName() + { + return $this->connection ?: config('fleetbase.connection.db', 'mysql'); + } + + protected static function booted(): void + { + static::creating(function (DatabaseBackup $backup) { + $backup->uuid ??= (string) Str::uuid(); + }); + } + + /** + * Run records are kept for a year, long after their files have been trimmed. + */ + public function prunable() + { + return static::where('started_at', '<', now()->subYear()); + } + + /** + * The shape the admin console reads. + */ + public function toAdminArray(): array + { + return [ + 'id' => $this->uuid, + 'connection' => $this->connection_name, + 'database' => $this->database, + 'status' => $this->status, + 'trigger' => $this->trigger, + 'disk' => $this->disk, + 'path' => $this->path, + 'size_bytes' => $this->size_bytes, + 'duration_ms' => $this->duration_ms, + 'error' => $this->error, + 'started_at' => $this->started_at?->toIso8601String(), + 'completed_at' => $this->completed_at?->toIso8601String(), + 'pruned_at' => $this->pruned_at?->toIso8601String(), + ]; + } +} diff --git a/src/Notifications/DatabaseBackupFailed.php b/src/Notifications/DatabaseBackupFailed.php new file mode 100644 index 00000000..d320dca5 --- /dev/null +++ b/src/Notifications/DatabaseBackupFailed.php @@ -0,0 +1,69 @@ + + */ + public array $failures; + + /** + * @param array $failed + */ + public function __construct(array $failed) + { + $this->failures = array_map(fn (DatabaseBackup $backup) => [ + 'connection' => (string) $backup->connection_name, + 'database' => (string) $backup->database, + 'error' => $backup->error, + ], $failed); + } + + /** + * @return array + */ + public function via($notifiable) + { + return ['mail']; + } + + /** + * @return MailMessage + */ + public function toMail($notifiable) + { + $app = config('app.name'); + $message = (new MailMessage()) + ->error() + ->subject($app . ' database backup failed') + ->line('The database backup that just ran did not complete, so no new backup was stored for:'); + + foreach ($this->failures as $failure) { + $message->line($failure['database'] . ' (' . $failure['connection'] . '): ' . ($failure['error'] ?: 'unknown error')); + } + + return $message + ->line('Older backups were left in place.') + ->action('Review database backups', Utils::consoleUrl('admin/database-backups')); + } + + /** + * @return array + */ + public function toArray($notifiable) + { + return ['failures' => $this->failures]; + } +} diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php index 37732a92..25a165aa 100644 --- a/src/Providers/CoreServiceProvider.php +++ b/src/Providers/CoreServiceProvider.php @@ -101,7 +101,7 @@ class CoreServiceProvider extends ServiceProvider \Fleetbase\Console\Commands\PurgeScheduledTaskLogs::class, \Fleetbase\Console\Commands\PurgeOrphanedModelRecords::class, \Fleetbase\Console\Commands\DeleteUser::class, - \Fleetbase\Console\Commands\BackupDatabase\MysqlS3Backup::class, + \Fleetbase\Console\Commands\BackupDatabase::class, \Fleetbase\Console\Commands\TelemetryPing::class, ]; @@ -144,7 +144,7 @@ public function register() $this->mergeConfigFrom(__DIR__ . '/../../config/schedule-monitor.php', 'schedule-monitor'); $this->mergeConfigFrom(__DIR__ . '/../../config/excel.php', 'excel'); $this->mergeConfigFrom(__DIR__ . '/../../config/sentry.php', 'sentry'); - $this->mergeConfigFrom(__DIR__ . '/../../config/laravel-mysql-s3-backup.php', 'laravel-mysql-s3-backup'); + $this->mergeConfigFrom(__DIR__ . '/../../config/database-backups.php', 'database-backups'); $this->mergeConfigFrom(__DIR__ . '/../../config/responsecache.php', 'responsecache'); $this->mergeConfigFrom(__DIR__ . '/../../config/image.php', 'image'); $this->mergeConfigFrom(__DIR__ . '/../../config/sms.php', 'sms'); @@ -216,6 +216,9 @@ public function boot() // available in sandbox within an hour of being created in // production, but infrequent enough to avoid unnecessary DB load. $schedule->command('sandbox:sync')->hourly()->name('sandbox-sync')->withoutOverlapping(); + // Database backups run on the schedule an administrator sets under Admin → Database + // Backups (or the DB_BACKUP_* environment defaults), and not at all while disabled. + \Fleetbase\Support\DatabaseBackupSettings::schedule($schedule); }); $this->registerObservers(); $this->registerExpansionsFrom(); diff --git a/src/Services/DatabaseBackup/DatabaseBackupException.php b/src/Services/DatabaseBackup/DatabaseBackupException.php new file mode 100644 index 00000000..2904d296 --- /dev/null +++ b/src/Services/DatabaseBackup/DatabaseBackupException.php @@ -0,0 +1,10 @@ + one record per connection + * + * @throws DatabaseBackupException when another backup is already running + */ + public function run(string $trigger = DatabaseBackup::TRIGGER_CONSOLE, ?array $connections = null, ?array $settings = null): array + { + $settings ??= DatabaseBackupSettings::settings(); + $connections = $connections ?: $settings['connections']; + + $lock = $this->acquireLock(); + if ($lock === false) { + throw new DatabaseBackupException('Another database backup is already running.'); + } + + try { + $records = []; + foreach ($connections as $connection) { + $records[] = $this->backupConnection((string) $connection, $trigger, $settings); + } + + $failed = array_values(array_filter($records, fn (DatabaseBackup $record) => $record->status === DatabaseBackup::STATUS_FAILED)); + + if ($failed) { + $this->notifyFailure($failed, $settings); + } elseif ($records) { + $this->pruneQuietly($settings); + } + + return $records; + } finally { + if (is_object($lock)) { + $lock->release(); + } + } + } + + /** + * Dump, verify and upload one connection's database, recording the outcome. + */ + public function backupConnection(string $connection, string $trigger, array $settings): DatabaseBackup + { + $database = (string) config("database.connections.{$connection}.database", $connection); + $started = hrtime(true); + $record = DatabaseBackup::create([ + 'connection_name' => $connection, + 'database' => $database, + 'status' => DatabaseBackup::STATUS_RUNNING, + 'trigger' => $trigger, + 'disk' => $settings['disk'], + 'started_at' => now(), + ]); + + $file = null; + try { + $file = $this->dump($connection, $this->fileName($database)); + $size = (int) filesize($file); + + if ($size < $settings['min_size_bytes']) { + throw new DatabaseBackupException(sprintf('The compressed dump is %d bytes, below the %d byte minimum.', $size, $settings['min_size_bytes'])); + } + + $path = $this->objectPath($settings['path'], basename($file)); + $this->upload($this->disk($settings), $file, $path, $size); + + $record->fill([ + 'status' => DatabaseBackup::STATUS_COMPLETED, + 'path' => $path, + 'size_bytes' => $size, + ]); + } catch (\Throwable $e) { + $record->fill([ + 'status' => DatabaseBackup::STATUS_FAILED, + 'error' => mb_substr($e->getMessage(), 0, 4000), + ]); + + Log::error('Database backup failed', ['connection' => $connection, 'database' => $database, 'error' => $e->getMessage()]); + } finally { + if ($file && is_file($file)) { + @unlink($file); + } + } + + $record->fill([ + 'duration_ms' => (int) ((hrtime(true) - $started) / 1_000_000), + 'completed_at' => now(), + ])->save(); + + return $record; + } + + /** + * Stream the dump client's output into a gzip file and verify it finished. + * + * @return string the path of the compressed dump + */ + public function dump(string $connection, string $fileName): string + { + $config = config("database.connections.{$connection}"); + if (!is_array($config) || !in_array($config['driver'] ?? null, ['mysql', 'mariadb'], true)) { + throw new DatabaseBackupException("Connection [{$connection}] is not a MySQL connection."); + } + + $directory = rtrim((string) config('database-backups.tmp_dir', sys_get_temp_dir()), '/'); + if (!is_dir($directory) && !@mkdir($directory, 0700, true) && !is_dir($directory)) { + throw new DatabaseBackupException("Cannot create the backup directory {$directory}."); + } + + $file = $directory . '/' . $fileName; + $gz = @gzopen($file, 'wb6'); + if ($gz === false) { + throw new DatabaseBackupException("Cannot write {$file}."); + } + + $tail = ''; + $stderr = ''; + + try { + $process = $this->makeProcess($this->dumpCommand($config), ['MYSQL_PWD' => (string) ($config['password'] ?? '')]); + $process->setTimeout((int) config('database-backups.timeout', 7200)); + $process->start(); + + foreach ($process as $type => $data) { + if ($type === Process::OUT) { + gzwrite($gz, $data); + $tail = substr($tail . $data, -512); + } else { + $stderr .= $data; + } + } + + $exitCode = $process->wait(); + } catch (\Throwable $e) { + gzclose($gz); + @unlink($file); + + throw new DatabaseBackupException('The dump could not run: ' . $e->getMessage(), 0, $e); + } + + gzclose($gz); + + if ($exitCode !== 0) { + @unlink($file); + + throw new DatabaseBackupException(sprintf('The dump exited with code %d: %s', $exitCode, trim($stderr) ?: 'no error output')); + } + + if (!str_contains($tail, static::COMPLETION_MARKER)) { + @unlink($file); + + throw new DatabaseBackupException('The dump ended without its completion marker, so it is incomplete.' . (trim($stderr) ? ' ' . trim($stderr) : '')); + } + + return $file; + } + + /** + * The dump client's argument list. The password travels in MYSQL_PWD, never on the + * command line, where any process listing would show it. + */ + public function dumpCommand(array $config): array + { + $command = [(string) config('database-backups.dump_binary', 'mysqldump')]; + + if (!empty($config['unix_socket'])) { + $command[] = '--socket=' . $config['unix_socket']; + } else { + $command[] = '--host=' . ($config['host'] ?? '127.0.0.1'); + $command[] = '--port=' . ($config['port'] ?? 3306); + } + + $command[] = '--user=' . ($config['username'] ?? 'root'); + + return array_merge( + $command, + (array) config('database-backups.dump_args', []), + (array) config('database-backups.extra_dump_args', []), + [(string) $config['database']] + ); + } + + /** + * Upload the file and confirm the stored object is the size we wrote. + */ + public function upload(Filesystem $disk, string $file, string $path, int $size): void + { + $stream = fopen($file, 'rb'); + + try { + $disk->writeStream($path, $stream); + } finally { + if (is_resource($stream)) { + fclose($stream); + } + } + + $stored = $disk->size($path); + if ((int) $stored !== $size) { + throw new DatabaseBackupException(sprintf('The uploaded backup is %d bytes but the local dump is %d bytes.', $stored, $size)); + } + } + + /** + * Delete backups beyond the retention limits, always keeping each database's newest. + * + * Only files following this command's naming scheme are considered, so nothing else + * stored under the same prefix is ever touched. + * + * @return array the deleted paths + */ + public function prune(array $settings): array + { + if (!$settings['retention_days'] && !$settings['retention_count']) { + return []; + } + + $disk = $this->disk($settings); + $cutoff = $settings['retention_days'] ? now()->subDays($settings['retention_days'])->format('Ymd-His') : null; + $groups = []; + + foreach ($disk->files($settings['path']) as $path) { + if (preg_match('/^(.+)_backup-(\d{8}-\d{6})\.sql(?:\.gz)?$/', basename($path), $matches)) { + $groups[$matches[1]][$path] = $matches[2]; + } + } + + $delete = []; + foreach ($groups as $files) { + arsort($files); + $index = 0; + foreach ($files as $path => $timestamp) { + $tooMany = $settings['retention_count'] && $index >= $settings['retention_count']; + $tooOld = $cutoff && $timestamp < $cutoff; + + if ($index > 0 && ($tooMany || $tooOld)) { + $delete[] = $path; + } + $index++; + } + } + + if ($delete) { + $disk->delete($delete); + DatabaseBackup::where('disk', $settings['disk'])->whereIn('path', $delete)->update(['pruned_at' => now()]); + } + + return $delete; + } + + /** + * The disk backups are written to, with the bucket override applied and errors thrown + * rather than reported as `false`. + */ + public function disk(array $settings): Filesystem + { + $config = config("filesystems.disks.{$settings['disk']}"); + if (!is_array($config)) { + throw new DatabaseBackupException("Filesystem disk [{$settings['disk']}] is not configured."); + } + + if ($settings['bucket'] && ($config['driver'] ?? null) === 's3') { + $config['bucket'] = $settings['bucket']; + } + + $config['throw'] = true; + + return $this->buildDisk($config); + } + + public function fileName(string $database): string + { + $environment = str_replace([' ', '-'], '_', (string) config('app.env', 'local')); + + return sprintf('%s_%s_backup-%s.sql.gz', $environment, $database, now()->format('Ymd-His')); + } + + public function objectPath(string $prefix, string $fileName): string + { + return ltrim(trim($prefix, '/') . '/' . $fileName, '/'); + } + + protected function pruneQuietly(array $settings): void + { + try { + $this->prune($settings); + } catch (\Throwable $e) { + Log::warning('Database backup retention failed', ['error' => $e->getMessage()]); + } + } + + /** + * @param array $failed + */ + protected function notifyFailure(array $failed, array $settings): void + { + if (!$settings['notify_on_failure'] || !$settings['notify_emails']) { + return; + } + + try { + Notification::route('mail', $settings['notify_emails'])->notify(new DatabaseBackupFailed($failed)); + } catch (\Throwable $e) { + Log::error('Could not send the database backup failure notification', ['error' => $e->getMessage()]); + } + } + + /** + * @return object|bool a lock to release, true when the cache cannot lock, or false when held elsewhere + */ + protected function acquireLock() + { + try { + $lock = Cache::lock(static::LOCK_KEY, 4 * 3600); + } catch (\Throwable $e) { + return true; + } + + return $lock->get() ? $lock : false; + } + + protected function makeProcess(array $command, array $env): Process + { + return new Process($command, null, $env); + } + + protected function buildDisk(array $config): Filesystem + { + return Storage::build($config); + } +} diff --git a/src/Support/DatabaseBackupSettings.php b/src/Support/DatabaseBackupSettings.php new file mode 100644 index 00000000..807e9b68 --- /dev/null +++ b/src/Support/DatabaseBackupSettings.php @@ -0,0 +1,188 @@ + config('database-backups.enabled', false), + 'frequency' => config('database-backups.frequency', 'daily'), + 'time' => config('database-backups.time', '00:00'), + 'day_of_week' => config('database-backups.day_of_week', 0), + 'disk' => config('database-backups.disk', 's3'), + 'bucket' => config('database-backups.bucket'), + 'path' => config('database-backups.path', ''), + 'connections' => config('database-backups.connections', ['mysql', 'sandbox']), + 'retention_days' => config('database-backups.retention_days', 30), + 'retention_count' => config('database-backups.retention_count'), + 'min_size_bytes' => config('database-backups.min_size_bytes', 1024), + 'notify_on_failure' => config('database-backups.notify_on_failure', false), + 'notify_emails' => config('database-backups.notify_emails', []), + ]); + } + + /** + * The effective settings: the environment defaults with the stored override applied. + * + * A missing database (a fresh install, unit tests) falls back to the defaults rather than + * failing whatever asked — most importantly the scheduler. + */ + public static function settings(): array + { + try { + $stored = Setting::where('key', 'system.' . static::SETTING_KEY)->value('value'); + } catch (\Throwable $e) { + $stored = null; + } + + return static::normalize(array_merge(static::defaults(), is_array($stored) ? $stored : [])); + } + + /** + * Persist administrator settings. + */ + public static function store(array $settings): array + { + $settings = static::normalize(array_merge(static::defaults(), $settings)); + + Setting::configureSystem(static::SETTING_KEY, $settings); + + return $settings; + } + + /** + * Drop the stored override so the environment defaults apply again. + */ + public static function reset(): array + { + Setting::where('key', 'system.' . static::SETTING_KEY)->delete(); + + return static::settings(); + } + + /** + * Coerce settings into their canonical shape. + */ + public static function normalize(array $settings): array + { + $frequency = in_array($settings['frequency'] ?? null, static::FREQUENCIES, true) ? $settings['frequency'] : 'daily'; + $time = is_string($settings['time'] ?? null) && preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $settings['time']) ? $settings['time'] : '00:00'; + $bucket = trim((string) ($settings['bucket'] ?? '')); + + return [ + 'enabled' => filter_var($settings['enabled'] ?? false, FILTER_VALIDATE_BOOLEAN), + 'frequency' => $frequency, + 'time' => $time, + 'day_of_week' => min(6, max(0, (int) ($settings['day_of_week'] ?? 0))), + 'disk' => (string) ($settings['disk'] ?? 's3') ?: 's3', + 'bucket' => $bucket === '' ? null : $bucket, + 'path' => trim((string) ($settings['path'] ?? ''), "/ \t\n\r"), + 'connections' => static::stringList($settings['connections'] ?? []), + 'retention_days' => static::positiveIntOrNull($settings['retention_days'] ?? null), + 'retention_count' => static::positiveIntOrNull($settings['retention_count'] ?? null), + 'min_size_bytes' => max(0, (int) ($settings['min_size_bytes'] ?? 1024)), + 'notify_on_failure' => filter_var($settings['notify_on_failure'] ?? false, FILTER_VALIDATE_BOOLEAN), + 'notify_emails' => static::stringList($settings['notify_emails'] ?? []), + ]; + } + + /** + * The cron expression for the configured frequency, in UTC. + */ + public static function cronExpression(array $settings): string + { + [$hour, $minute] = array_map('intval', explode(':', $settings['time'])); + + return match ($settings['frequency']) { + 'hourly' => "{$minute} * * * *", + 'every_six_hours' => $minute . ' ' . implode(',', [$hour % 6, $hour % 6 + 6, $hour % 6 + 12, $hour % 6 + 18]) . ' * * *', + 'every_twelve_hours' => $minute . ' ' . implode(',', [$hour % 12, $hour % 12 + 12]) . ' * * *', + 'weekly' => "{$minute} {$hour} * * {$settings['day_of_week']}", + default => "{$minute} {$hour} * * *", + }; + } + + /** + * Register the backup on the scheduler when backups are enabled. + * + * @param \Illuminate\Console\Scheduling\Schedule $schedule + */ + public static function schedule($schedule, ?array $settings = null): void + { + $settings ??= static::settings(); + + if (!$settings['enabled']) { + return; + } + + $schedule->command('db:backup --no-interaction --trigger=scheduled') + ->cron(static::cronExpression($settings)) + ->timezone('UTC') + ->name('database-backup') + ->withoutOverlapping(240); + } + + /** + * The filesystem disks a backup can be written to. + */ + public static function disks(): array + { + $disks = []; + foreach ((array) config('filesystems.disks', []) as $name => $disk) { + $disks[] = ['name' => (string) $name, 'driver' => (string) data_get($disk, 'driver', '')]; + } + + return $disks; + } + + /** + * The database connections that can be dumped: those using a MySQL-compatible driver. + */ + public static function connections(): array + { + $connections = []; + foreach ((array) config('database.connections', []) as $name => $connection) { + if (in_array(data_get($connection, 'driver'), ['mysql', 'mariadb'], true)) { + $connections[] = (string) $name; + } + } + + return $connections; + } + + protected static function stringList($value): array + { + if (is_string($value)) { + $value = explode(',', $value); + } + + return array_values(array_unique(array_filter(array_map(fn ($item) => trim((string) $item), (array) $value), fn ($item) => $item !== ''))); + } + + protected static function positiveIntOrNull($value): ?int + { + if ($value === null || $value === '' || !is_numeric($value) || (int) $value < 1) { + return null; + } + + return (int) $value; + } +} diff --git a/src/routes.php b/src/routes.php index fe76c767..a8bf720e 100644 --- a/src/routes.php +++ b/src/routes.php @@ -254,6 +254,13 @@ function ($router, $controller) { $router->post('test-notification-channels-config', $controller('testNotificationChannelsConfig')); } ); + $router->group(['prefix' => 'database-backups'], function ($router) { + $router->get('settings', 'DatabaseBackupController@getSettings'); + $router->post('settings', 'DatabaseBackupController@saveSettings'); + $router->delete('settings', 'DatabaseBackupController@resetSettings'); + $router->get('runs', 'DatabaseBackupController@runs'); + $router->post('run', 'DatabaseBackupController@run'); + }); $router->group(['prefix' => 'rate-limits'], function ($router) { $router->get('settings', 'RateLimitController@getSettings'); $router->post('settings', 'RateLimitController@saveSettings'); diff --git a/tests/Unit/Console/BackupDatabaseCommandsTest.php b/tests/Unit/Console/BackupDatabaseCommandsTest.php deleted file mode 100644 index 98021f3b..00000000 --- a/tests/Unit/Console/BackupDatabaseCommandsTest.php +++ /dev/null @@ -1,386 +0,0 @@ -timeout = $timeout; - } - - public function run(): void - { - $this->ran = true; - } - - public function isSuccessful(): bool - { - return $this->successful; - } - - public function getErrorOutput(): string - { - return $this->errorOutput; - } -} - -class BackupDatabaseUploaderFake -{ - public bool $uploaded = false; - - public function __construct( - public object $s3, - public string $fileName, - public array $options, - private ?MultipartUploadException $exception = null, - ) { - } - - public function upload(): void - { - if ($this->exception) { - throw $this->exception; - } - - $this->uploaded = true; - } -} - -class BackupDatabaseTrimmerFake extends S3BackupTrimmer -{ - public bool $ran = false; - - public function __construct(int $days, string $bucket) - { - $this->days = $days; - $this->bucket = $bucket; - $this->when = now()->subDays($this->days)->startOfDay(); - } - - public function run(): void - { - $this->ran = true; - } -} - -class BackupDatabaseTestCommand extends MysqlS3Backup -{ - public array $processes = []; - public array $s3Configs = []; - public array $uploaders = []; - public array $deletedFiles = []; - public array $trimmers = []; - public bool $processSuccessful = true; - public string $processErrorOutput = ''; - public ?MultipartUploadException $uploadException = null; - - protected function makeProcess(string $command) - { - return $this->processes[] = new BackupDatabaseProcessFake($command, $this->processSuccessful, $this->processErrorOutput); - } - - protected function makeS3Client(array $config) - { - $this->s3Configs[] = $config; - - return (object) ['config' => $config]; - } - - protected function makeMultipartUploader($s3, string $fileName, array $options) - { - return $this->uploaders[] = new BackupDatabaseUploaderFake($s3, $fileName, $options, $this->uploadException); - } - - protected function deleteLocalFile(string $fileName): void - { - $this->deletedFiles[] = $fileName; - } - - protected function makeBackupTrimmer($days, $bucket): S3BackupTrimmer - { - $trimmer = new BackupDatabaseTrimmerFake((int) $days, $bucket); - $this->trimmers[] = $trimmer; - - return $trimmer; - } -} - -class BackupDatabaseS3Fake -{ - public array $deletedPayloads = []; - - public function __construct(public array $contents) - { - } - - public function listObjects(array $payload): array - { - return ['Contents' => $this->contents]; - } - - public function deleteObjects(array $payload): void - { - $this->deletedPayloads[] = $payload; - } -} - -class BackupDatabaseTestTrimmer extends S3BackupTrimmer -{ - public function __construct(int $days, string $bucket, public BackupDatabaseS3Fake $s3) - { - parent::__construct($days, $bucket); - } - - protected function makeS3Client(array $config) - { - return $this->s3; - } -} - -function backup_database_container(array $overrides = []): void -{ - Container::setInstance(new BackupDatabaseCommandContainer()); - - bind_test_container(array_replace_recursive([ - 'database.connections.mysql.host' => 'db.example.test', - 'database.connections.mysql.port' => 3307, - 'database.connections.mysql.username' => 'fleetbase', - 'database.connections.mysql.password' => 'secret value', - 'database.connections.mysql.database' => 'fleetbase', - 'database.connections.sandbox.database' => 'fleetbase_sandbox', - 'laravel-mysql-s3-backup.backup_dir' => '/tmp/fleetbase-backups', - 'laravel-mysql-s3-backup.filename' => '%s-%s.sql', - 'laravel-mysql-s3-backup.gzip' => true, - 'laravel-mysql-s3-backup.custom_mysqldump_args' => '--no-tablespaces', - 'laravel-mysql-s3-backup.sql_timout' => 120, - 'laravel-mysql-s3-backup.keep_local_copy' => false, - 'laravel-mysql-s3-backup.rolling_backup_days' => 7, - 'laravel-mysql-s3-backup.s3' => [ - 'bucket' => 'fleetbase-backups', - 'folder' => 'daily', - 'region' => 'ap-southeast-1', - 'version' => 'latest', - ], - ], $overrides)); - - Facade::clearResolvedInstances(); -} - -function backup_database_call(object $target, string $method, mixed ...$arguments): mixed -{ - $reflection = new ReflectionMethod($target, $method); - $reflection->setAccessible(true); - - return $reflection->invoke($target, ...$arguments); -} - -afterEach(function () { - Carbon::setTestNow(); - Facade::clearResolvedInstances(); -}); - -it('creates s3 backup trimmers through the static factory', function () { - Carbon::setTestNow(Carbon::parse('2026-07-18 09:30:45')); - - $trimmer = S3BackupTrimmer::make(14, 'fleetbase-backups'); - - expect($trimmer)->toBeInstanceOf(S3BackupTrimmer::class) - ->and($trimmer->days)->toBe(14) - ->and($trimmer->bucket)->toBe('fleetbase-backups') - ->and($trimmer->when->toDateTimeString())->toBe('2026-07-04 00:00:00'); -}); - -it('builds real backup helper collaborators without invoking external services', function () { - backup_database_container(); - $command = new MysqlS3Backup(); - - $process = backup_database_call($command, 'makeProcess', 'echo fleetbase'); - $s3 = backup_database_call($command, 'makeS3Client', [ - 'region' => 'ap-southeast-1', - 'version' => 'latest', - 'credentials' => [ - 'key' => 'test-key', - 'secret' => 'test-secret', - ], - ]); - $fileName = tempnam(sys_get_temp_dir(), 'fleetbase-backup-helper-'); - file_put_contents($fileName, 'sql dump'); - - $uploader = backup_database_call($command, 'makeMultipartUploader', $s3, $fileName, [ - 'bucket' => 'fleetbase-backups', - 'key' => 'daily/' . basename($fileName), - ]); - $trimmer = backup_database_call($command, 'makeBackupTrimmer', 3, 'fleetbase-backups'); - - expect($process)->toBeInstanceOf(Process::class) - ->and($process->getCommandLine())->toBe('echo fleetbase') - ->and($s3)->toBeInstanceOf(S3Client::class) - ->and($uploader)->toBeInstanceOf(MultipartUploader::class) - ->and($trimmer)->toBeInstanceOf(S3BackupTrimmer::class) - ->and(file_exists($fileName))->toBeTrue(); - - backup_database_call($command, 'deleteLocalFile', $fileName); - - expect(file_exists($fileName))->toBeFalse(); -}); - -it('builds mysql and sandbox dump commands uploads to configured s3 folder and trims rolling backups', function () { - backup_database_container(); - Carbon::setTestNow(Carbon::parse('2026-07-18 09:30:45')); - - $command = new BackupDatabaseTestCommand(); - $command->setLaravel(app()); - $tester = new CommandTester($command); - - expect($tester->execute([], ['verbosity' => OutputInterface::VERBOSITY_DEBUG]))->toBe(0) - ->and($command->processes)->toHaveCount(2) - ->and($command->processes[0]->command)->toMatch("/^mysqldump --host='db\\.example\\.test' --port='3307' --user='fleetbase' --password='secret value' --single-transaction --routines --triggers --no-tablespaces 'fleetbase' \\| gzip > '\\/tmp\\/fleetbase-backups\\/fleetbase-\\d{8}-\\d{6}\\.sql\\.gz'$/") - ->and($command->processes[1]->command)->toMatch("/^mysqldump --host='db\\.example\\.test' --port='3307' --user='fleetbase' --password='secret value' --single-transaction --routines --triggers --no-tablespaces 'fleetbase_sandbox' \\| gzip > '\\/tmp\\/fleetbase-backups\\/fleetbase_sandbox-\\d{8}-\\d{6}\\.sql\\.gz'$/") - ->and($command->processes[0]->timeout)->toBe(120) - ->and($command->processes[0]->ran)->toBeTrue() - ->and($command->s3Configs)->toHaveCount(2) - ->and($command->uploaders)->toHaveCount(2) - ->and($command->uploaders[0]->uploaded)->toBeTrue(); - - $firstBackup = $command->uploaders[0]->fileName; - $secondBackup = $command->uploaders[1]->fileName; - - expect($command->uploaders[0]->options)->toBe([ - 'bucket' => 'fleetbase-backups', - 'key' => 'daily/' . basename($firstBackup), - ]) - ->and($command->deletedFiles)->toBe([ - $firstBackup, - $secondBackup, - ]) - ->and($command->trimmers)->toHaveCount(2) - ->and($command->trimmers[0]->ran)->toBeTrue() - ->and($command->trimmers[0]->days)->toBe(7) - ->and($command->trimmers[0]->bucket)->toBe('fleetbase-backups') - ->and($tester->getDisplay())->toContain('Running backup for database `fleetbase`') - ->and($tester->getDisplay())->toContain('Running command: mysqldump'); -}); - -it('stops backup processing when a database dump fails before upload or cleanup', function () { - backup_database_container([ - 'laravel-mysql-s3-backup.gzip' => false, - 'laravel-mysql-s3-backup.custom_mysqldump_args' => null, - ]); - Carbon::setTestNow(Carbon::parse('2026-07-18 10:00:00')); - - $command = new BackupDatabaseTestCommand(); - $command->processSuccessful = false; - $command->processErrorOutput = 'mysqldump failed'; - $command->setLaravel(app()); - $tester = new CommandTester($command); - - expect($tester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]))->toBe(0) - ->and($command->processes)->toHaveCount(1) - ->and($command->processes[0]->command)->toMatch("/^mysqldump --host='db\\.example\\.test' --port='3307' --user='fleetbase' --password='secret value' --single-transaction --routines --triggers 'fleetbase' > '\\/tmp\\/fleetbase-backups\\/fleetbase-\\d{8}-\\d{6}\\.sql'$/") - ->and($command->uploaders)->toBeEmpty() - ->and($command->deletedFiles)->toBeEmpty() - ->and($command->trimmers)->toBeEmpty() - ->and($tester->getDisplay())->toContain('mysqldump failed'); -}); - -it('reports multipart upload failures while still cleaning up local backups', function () { - backup_database_container([ - 'laravel-mysql-s3-backup.keep_local_copy' => false, - 'laravel-mysql-s3-backup.rolling_backup_days' => null, - ]); - Carbon::setTestNow(Carbon::parse('2026-07-18 11:00:00')); - - $command = new BackupDatabaseTestCommand(); - $command->uploadException = new MultipartUploadException(new UploadState([ - 'Bucket' => 'fleetbase-backups', - 'Key' => 'daily/fleetbase.sql.gz', - ])); - $command->setLaravel(app()); - $tester = new CommandTester($command); - - expect($tester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]))->toBe(0) - ->and($command->processes)->toHaveCount(2) - ->and($command->uploaders)->toHaveCount(2) - ->and($command->uploaders[0]->uploaded)->toBeFalse() - ->and($command->uploaders[1]->uploaded)->toBeFalse() - ->and($command->deletedFiles)->toBe([ - $command->uploaders[0]->fileName, - $command->uploaders[1]->fileName, - ]) - ->and($command->trimmers)->toBeEmpty() - ->and($tester->getDisplay())->toContain('Unable to upload "' . $command->uploaders[0]->fileName . '" backup to s3. Error: An exception occurred while performing a multipart upload') - ->and($tester->getDisplay())->toContain('Deleting local backup file ' . $command->uploaders[0]->fileName); -}); - -it('trims only old backup objects inside the configured s3 folder', function () { - backup_database_container([ - 'laravel-mysql-s3-backup.s3.folder' => 'daily', - ]); - Carbon::setTestNow(Carbon::parse('2026-07-18 12:00:00')); - - $s3 = new BackupDatabaseS3Fake([ - ['Key' => 'daily/fleetbase-20260701-000000.sql.gz'], - ['Key' => 'daily/fleetbase-20260717-000000.sql.gz'], - ['Key' => 'weekly/fleetbase-20260701-000000.sql.gz'], - ]); - - $trimmer = new BackupDatabaseTestTrimmer(7, 'fleetbase-backups', $s3); - $trimmer->run(); - - expect($trimmer->days)->toBe(7) - ->and($trimmer->bucket)->toBe('fleetbase-backups') - ->and($trimmer->when->toDateTimeString())->toBe('2026-07-11 00:00:00') - ->and($s3->deletedPayloads)->toBe([[ - 'Bucket' => 'fleetbase-backups', - 'Delete' => [ - 'Objects' => [ - ['Key' => 'daily/fleetbase-20260701-000000.sql.gz'], - ], - ], - ]]); -}); - -it('does not call s3 delete when no backups are old enough to trim', function () { - backup_database_container([ - 'laravel-mysql-s3-backup.s3.folder' => null, - ]); - Carbon::setTestNow(Carbon::parse('2026-07-18 12:00:00')); - - $s3 = new BackupDatabaseS3Fake([ - ['Key' => 'fleetbase-20260717-000000.sql.gz'], - ]); - - (new BackupDatabaseTestTrimmer(7, 'fleetbase-backups', $s3))->run(); - - expect($s3->deletedPayloads)->toBeEmpty(); -}); diff --git a/tests/Unit/DatabaseBackupsTest.php b/tests/Unit/DatabaseBackupsTest.php new file mode 100644 index 00000000..22a123f2 --- /dev/null +++ b/tests/Unit/DatabaseBackupsTest.php @@ -0,0 +1,938 @@ +commands[] = $command; + $this->envs[] = $env; + + return parent::makeProcess([PHP_BINARY, '-r', $this->script], $env); + } + + protected function buildDisk(array $config): Filesystem + { + $this->diskConfigs[] = $config; + + return $this->diskOverride ?? parent::buildDisk($config); + } +} + +/** + * A real local disk whose reported object size or listing can be made to misbehave. + */ +class DatabaseBackupsDiskFake extends FilesystemAdapter +{ + public ?int $reportedSize = null; + public ?Throwable $listingError = null; + + public static function at(string $root): self + { + $adapter = new LocalFilesystemAdapter($root); + + return new self(new Flysystem($adapter), $adapter, ['root' => $root, 'throw' => true]); + } + + public function size($path) + { + return $this->reportedSize ?? parent::size($path); + } + + public function files($directory = null, $recursive = false) + { + if ($this->listingError) { + throw $this->listingError; + } + + return parent::files($directory, $recursive); + } +} + +class DatabaseBackupsNotificationDispatcherFake implements NotificationDispatcher +{ + public array $sent = []; + public bool $fail = false; + + public function send($notifiables, $notification) + { + if ($this->fail) { + throw new RuntimeException('mail is down'); + } + + $this->sent[] = [$notifiables, $notification]; + } + + public function sendNow($notifiables, $notification, ?array $channels = null) + { + $this->send($notifiables, $notification); + } +} + +class DatabaseBackupsBusFake implements BusDispatcher +{ + public array $dispatched = []; + + public function dispatch($command) + { + $this->dispatched[] = $command; + } + + public function dispatchSync($command, $handler = null) + { + $this->dispatch($command); + } + + public function dispatchNow($command, $handler = null) + { + $this->dispatch($command); + } + + public function hasCommandHandler($command) + { + return false; + } + + public function getCommandHandler($command) + { + return false; + } + + public function pipeThrough(array $pipes) + { + return $this; + } + + public function map(array $map) + { + return $this; + } +} + +class DatabaseBackupsServiceStub extends DatabaseBackupService +{ + public array $calls = []; + + public function __construct(public array|Throwable $result = []) + { + } + + public function run(string $trigger = DatabaseBackup::TRIGGER_CONSOLE, ?array $connections = null, ?array $settings = null): array + { + $this->calls[] = [$trigger, $connections]; + + if ($this->result instanceof Throwable) { + throw $this->result; + } + + return $this->result; + } +} + +class DatabaseBackupsScheduleFake +{ + public array $events = []; + + public function command(string $command): DatabaseBackupsScheduledEventFake + { + return $this->events[] = new DatabaseBackupsScheduledEventFake($command); + } +} + +class DatabaseBackupsScheduledEventFake +{ + public array $calls = []; + + public function __construct(public string $command) + { + } + + public function __call($method, $arguments) + { + $this->calls[$method] = $arguments; + + return $this; + } +} + +const DATABASE_BACKUPS_DUMP_OK = 'echo "-- MySQL dump 10.19\n", bin2hex(random_bytes(4000)), "\n-- Dump completed on 2026-10-06 0:00:01\n";'; +const DATABASE_BACKUPS_DUMP_DENIED = 'fwrite(STDERR, "mysqldump: Got error: 1045: Access denied"); exit(2);'; +const DATABASE_BACKUPS_DUMP_TRUNCATED = 'echo bin2hex(random_bytes(4000));'; +const DATABASE_BACKUPS_DUMP_TINY = 'echo "-- Dump completed\n";'; +const DATABASE_BACKUPS_DUMP_SILENT_ERR = 'echo bin2hex(random_bytes(10)); fwrite(STDERR, "warning: lost connection");'; + +function database_backups_root(): string +{ + return sys_get_temp_dir() . '/fleetbase-database-backups-test'; +} + +function database_backups_rmdir(string $directory): void +{ + if (!is_dir($directory)) { + return; + } + + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST) as $item) { + $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + + rmdir($directory); +} + +function database_backups_fixture(array $config = []): array +{ + EloquentModel::clearBootedModels(); + Container::setInstance(new DatabaseBackupsTestContainer()); + + $root = database_backups_root(); + database_backups_rmdir($root); + mkdir($root . '/disk', 0777, true); + mkdir($root . '/tmp', 0777, true); + + $sqlite = ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']; + + $container = bind_test_container(array_merge([ + 'app.name' => 'Fleetbase', + 'app.env' => 'production', + 'fleetbase.console.host' => 'https://console.fleetbase.test', + 'database.default' => 'mysql', + 'database.connections.mysql' => $sqlite, + 'database.connections.primary' => ['driver' => 'mysql', 'host' => 'db.example.test', 'port' => 3307, 'username' => 'fleetbase', 'password' => 'secret value', 'database' => 'fleetbase'], + 'database.connections.sandbox' => ['driver' => 'mysql', 'unix_socket' => '/run/mysqld.sock', 'username' => 'fleetbase', 'password' => 'sandbox secret', 'database' => 'fleetbase_sandbox'], + 'fleetbase.connection.db' => 'mysql', + 'filesystems.disks' => [ + 'backups' => ['driver' => 'local', 'root' => $root . '/disk'], + 's3' => ['driver' => 's3', 'bucket' => 'fleetbase-media', 'region' => 'ap-southeast-1'], + ], + 'database-backups.enabled' => true, + 'database-backups.frequency' => 'daily', + 'database-backups.time' => '02:30', + 'database-backups.day_of_week' => 0, + 'database-backups.disk' => 'backups', + 'database-backups.bucket' => null, + 'database-backups.path' => 'nightly', + 'database-backups.connections' => ['primary', 'sandbox'], + 'database-backups.retention_days' => 30, + 'database-backups.retention_count' => null, + 'database-backups.min_size_bytes' => 1024, + 'database-backups.notify_on_failure' => true, + 'database-backups.notify_emails' => ['ops@example.test'], + 'database-backups.dump_binary' => 'mysqldump', + 'database-backups.dump_args' => ['--single-transaction', '--no-tablespaces'], + 'database-backups.extra_dump_args' => ['--column-statistics=0'], + 'database-backups.timeout' => 60, + 'database-backups.tmp_dir' => $root . '/tmp', + ], $config)); + + $container->instance('cache', new CacheRepository(new ArrayStore())); + $container->instance('filesystem', new FilesystemManager($container)); + $notifications = new DatabaseBackupsNotificationDispatcherFake(); + $container->instance(NotificationDispatcher::class, $notifications); + $bus = new DatabaseBackupsBusFake(); + $container->instance(BusDispatcher::class, $bus); + Facade::clearResolvedInstances(); + + $capsule = new Capsule($container); + $capsule->addConnection($sqlite, 'mysql'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + $capsule->getDatabaseManager()->setDefaultConnection('mysql'); + $container->instance('db', $capsule->getDatabaseManager()); + $container->instance('db.schema', $capsule->getConnection('mysql')->getSchemaBuilder()); + Facade::clearResolvedInstance('db'); + Facade::clearResolvedInstance('db.schema'); + + $schema = $capsule->getConnection('mysql')->getSchemaBuilder(); + $schema->create('settings', function ($table) { + $table->increments('id'); + $table->string('key')->unique(); + $table->text('value')->nullable(); + }); + (require __DIR__ . '/../../migrations/2026_10_06_000000_create_database_backups_table.php')->up(); + + $validation = new ValidationFactory(new Translator(new ArrayLoader(), 'en')); + Request::macro('validate', function (array $rules) use ($validation) { + return $validation->make($this->all(), $rules)->validate(); + }); + + return compact('container', 'capsule', 'notifications', 'bus', 'root'); +} + +function database_backups_record(array $attributes): DatabaseBackup +{ + return DatabaseBackup::create(array_merge([ + 'connection_name' => 'primary', + 'database' => 'fleetbase', + 'status' => DatabaseBackup::STATUS_COMPLETED, + 'trigger' => DatabaseBackup::TRIGGER_SCHEDULED, + 'disk' => 'backups', + 'started_at' => now(), + ], $attributes)); +} + +function database_backups_command(DatabaseBackupService $service, array $input = []): array +{ + app()->instance(DatabaseBackupService::class, $service); + + $command = new BackupDatabase(); + $command->setLaravel(app()); + $tester = new CommandTester($command); + $code = $tester->execute($input); + + return [$code, $tester->getDisplay()]; +} + +afterEach(function () { + Carbon::setTestNow(); + + $macros = new ReflectionProperty(Request::class, 'macros'); + $macros->setAccessible(true); + $macros->setValue(null, array_diff_key($macros->getValue(), ['validate' => true])); + + database_backups_rmdir(database_backups_root()); + EloquentModel::clearBootedModels(); + Facade::clearResolvedInstances(); +}); + +// --------------------------------------------------------------------------------------- +// Settings +// --------------------------------------------------------------------------------------- + +test('database backup settings come from the environment until an administrator overrides them', function () { + database_backups_fixture(); + + $defaults = DatabaseBackupSettings::defaults(); + + expect($defaults)->toBe([ + 'enabled' => true, + 'frequency' => 'daily', + 'time' => '02:30', + 'day_of_week' => 0, + 'disk' => 'backups', + 'bucket' => null, + 'path' => 'nightly', + 'connections' => ['primary', 'sandbox'], + 'retention_days' => 30, + 'retention_count' => null, + 'min_size_bytes' => 1024, + 'notify_on_failure' => true, + 'notify_emails' => ['ops@example.test'], + ])->and(DatabaseBackupSettings::settings())->toBe($defaults); + + $stored = DatabaseBackupSettings::store(['frequency' => 'weekly', 'day_of_week' => 3, 'retention_count' => '5']); + + expect($stored['frequency'])->toBe('weekly') + ->and($stored['day_of_week'])->toBe(3) + ->and($stored['retention_count'])->toBe(5) + ->and(DatabaseBackupSettings::settings())->toBe($stored) + ->and(DatabaseBackupSettings::reset())->toBe($defaults); +}); + +test('database backup settings fall back to the defaults when the settings table is unreachable', function () { + $fixture = database_backups_fixture(); + $fixture['capsule']->getConnection('mysql')->getSchemaBuilder()->drop('settings'); + + expect(DatabaseBackupSettings::settings())->toBe(DatabaseBackupSettings::defaults()); +}); + +test('database backup settings normalize loose and invalid input', function () { + database_backups_fixture(); + + expect(DatabaseBackupSettings::normalize([ + 'enabled' => 'true', + 'frequency' => 'fortnightly', + 'time' => '25:00', + 'day_of_week' => 9, + 'disk' => '', + 'bucket' => ' ', + 'path' => '/backups/db/', + 'connections' => 'primary, sandbox ,,primary', + 'retention_days' => '0', + 'retention_count' => 'many', + 'min_size_bytes' => -5, + 'notify_on_failure' => '1', + 'notify_emails' => [' ops@example.test ', ''], + ]))->toBe([ + 'enabled' => true, + 'frequency' => 'daily', + 'time' => '00:00', + 'day_of_week' => 6, + 'disk' => 's3', + 'bucket' => null, + 'path' => 'backups/db', + 'connections' => ['primary', 'sandbox'], + 'retention_days' => null, + 'retention_count' => null, + 'min_size_bytes' => 0, + 'notify_on_failure' => true, + 'notify_emails' => ['ops@example.test'], + ])->and(DatabaseBackupSettings::normalize([])['min_size_bytes'])->toBe(1024); +}); + +test('database backup frequencies translate to utc cron expressions', function () { + database_backups_fixture(); + $settings = fn (string $frequency, string $time = '14:05', int $day = 2) => ['frequency' => $frequency, 'time' => $time, 'day_of_week' => $day]; + + expect(DatabaseBackupSettings::cronExpression($settings('hourly')))->toBe('5 * * * *') + ->and(DatabaseBackupSettings::cronExpression($settings('every_six_hours')))->toBe('5 2,8,14,20 * * *') + ->and(DatabaseBackupSettings::cronExpression($settings('every_twelve_hours')))->toBe('5 2,14 * * *') + ->and(DatabaseBackupSettings::cronExpression($settings('daily')))->toBe('5 14 * * *') + ->and(DatabaseBackupSettings::cronExpression($settings('weekly')))->toBe('5 14 * * 2'); +}); + +test('database backups are scheduled only while enabled', function () { + database_backups_fixture(); + + $schedule = new DatabaseBackupsScheduleFake(); + DatabaseBackupSettings::schedule($schedule); + + expect($schedule->events)->toHaveCount(1) + ->and($schedule->events[0]->command)->toBe('db:backup --no-interaction --trigger=scheduled') + ->and($schedule->events[0]->calls)->toBe([ + 'cron' => ['30 2 * * *'], + 'timezone' => ['UTC'], + 'name' => ['database-backup'], + 'withoutOverlapping' => [240], + ]); + + $disabled = new DatabaseBackupsScheduleFake(); + DatabaseBackupSettings::schedule($disabled, array_merge(DatabaseBackupSettings::settings(), ['enabled' => false])); + + expect($disabled->events)->toBe([]); +}); + +test('database backup settings list the disks and the mysql connections', function () { + database_backups_fixture(); + + expect(DatabaseBackupSettings::disks())->toBe([ + ['name' => 'backups', 'driver' => 'local'], + ['name' => 's3', 'driver' => 's3'], + ])->and(DatabaseBackupSettings::connections())->toBe(['primary', 'sandbox']); +}); + +// --------------------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------------------- + +test('database backup service dumps uploads verifies and records every connection', function () { + $fixture = database_backups_fixture(); + Carbon::setTestNow(Carbon::parse('2026-10-06 00:00:05')); + + $service = new DatabaseBackupsServiceFake(); + $service->script = DATABASE_BACKUPS_DUMP_OK; + $records = $service->run(DatabaseBackup::TRIGGER_SCHEDULED); + + expect($records)->toHaveCount(2) + ->and($records[0]->status)->toBe(DatabaseBackup::STATUS_COMPLETED) + ->and($records[0]->path)->toBe('nightly/production_fleetbase_backup-20261006-000005.sql.gz') + ->and($records[1]->path)->toBe('nightly/production_fleetbase_sandbox_backup-20261006-000005.sql.gz') + ->and($records[0]->size_bytes)->toBeGreaterThan(1024) + ->and($records[0]->trigger)->toBe('scheduled') + ->and($records[0]->error)->toBeNull() + ->and($records[0]->duration_ms)->toBeInt() + ->and(DatabaseBackup::count())->toBe(2) + ->and(file_exists($fixture['root'] . '/disk/' . $records[0]->path))->toBeTrue() + ->and(glob($fixture['root'] . '/tmp/*'))->toBe([]) + ->and($fixture['notifications']->sent)->toBe([]); + + $sql = gzdecode(file_get_contents($fixture['root'] . '/disk/' . $records[0]->path)); + expect($sql)->toStartWith('-- MySQL dump')->toContain('-- Dump completed'); + + expect($service->commands[0])->toBe(['mysqldump', '--host=db.example.test', '--port=3307', '--user=fleetbase', '--single-transaction', '--no-tablespaces', '--column-statistics=0', 'fleetbase']) + ->and($service->commands[1])->toBe(['mysqldump', '--socket=/run/mysqld.sock', '--user=fleetbase', '--single-transaction', '--no-tablespaces', '--column-statistics=0', 'fleetbase_sandbox']) + ->and($service->envs)->toBe([['MYSQL_PWD' => 'secret value'], ['MYSQL_PWD' => 'sandbox secret']]) + ->and($service->diskConfigs[0]['throw'])->toBeTrue(); +}); + +test('database backup service fails loudly when the dump client fails and keeps old backups', function () { + $fixture = database_backups_fixture(); + Carbon::setTestNow(Carbon::parse('2026-10-06 00:00:05')); + file_put_contents($fixture['root'] . '/disk/old.txt', 'x'); + mkdir($fixture['root'] . '/disk/nightly'); + file_put_contents($fixture['root'] . '/disk/nightly/production_fleetbase_backup-20260101-000000.sql.gz', 'old'); + file_put_contents($fixture['root'] . '/disk/nightly/production_fleetbase_backup-20260102-000000.sql.gz', 'old'); + + $service = new DatabaseBackupsServiceFake(); + $service->script = DATABASE_BACKUPS_DUMP_DENIED; + $records = $service->run(DatabaseBackup::TRIGGER_MANUAL, ['primary']); + + expect($records)->toHaveCount(1) + ->and($records[0]->status)->toBe(DatabaseBackup::STATUS_FAILED) + ->and($records[0]->error)->toBe('The dump exited with code 2: mysqldump: Got error: 1045: Access denied') + ->and($records[0]->path)->toBeNull() + ->and($records[0]->completed_at)->not->toBeNull() + ->and(glob($fixture['root'] . '/tmp/*'))->toBe([]) + ->and(file_exists($fixture['root'] . '/disk/nightly/production_fleetbase_backup-20260101-000000.sql.gz'))->toBeTrue() + ->and($fixture['notifications']->sent)->toHaveCount(1); + + [$notifiable, $notification] = $fixture['notifications']->sent[0]; + expect($notifiable)->toBeInstanceOf(AnonymousNotifiable::class) + ->and($notifiable->routes['mail'])->toBe(['ops@example.test']) + ->and($notification)->toBeInstanceOf(DatabaseBackupFailed::class) + ->and($notification->failures)->toBe([[ + 'connection' => 'primary', + 'database' => 'fleetbase', + 'error' => 'The dump exited with code 2: mysqldump: Got error: 1045: Access denied', + ]]) + ->and(collect(app('log')->entries)->pluck(1))->toContain('Database backup failed'); +}); + +test('database backup service rejects incomplete, empty and undersized dumps', function () { + $fixture = database_backups_fixture(['database-backups.notify_on_failure' => false]); + + $service = new DatabaseBackupsServiceFake(); + $service->script = DATABASE_BACKUPS_DUMP_TRUNCATED; + expect($service->run('manual', ['primary'])[0]->error)->toBe('The dump ended without its completion marker, so it is incomplete.'); + + $service->script = DATABASE_BACKUPS_DUMP_SILENT_ERR; + expect($service->run('manual', ['primary'])[0]->error)->toBe('The dump ended without its completion marker, so it is incomplete. warning: lost connection'); + + $service->script = DATABASE_BACKUPS_DUMP_TINY; + expect($service->run('manual', ['primary'])[0]->error)->toMatch('/^The compressed dump is \d+ bytes, below the 1024 byte minimum\.$/'); + + $service->script = 'exit(3);'; + expect($service->run('manual', ['primary'])[0]->error)->toBe('The dump exited with code 3: no error output') + ->and(glob($fixture['root'] . '/tmp/*'))->toBe([]) + ->and(glob($fixture['root'] . '/disk/nightly/*') ?: [])->toBe([]) + ->and($fixture['notifications']->sent)->toBe([]); +}); + +test('database backup service refuses connections that are not mysql or are missing', function () { + database_backups_fixture(['database-backups.notify_emails' => []]); + + $service = new DatabaseBackupsServiceFake(); + $records = $service->run('console', ['mysql', 'nowhere']); + + expect($records[0]->error)->toBe('Connection [mysql] is not a MySQL connection.') + ->and($records[1]->database)->toBe('nowhere') + ->and($records[1]->error)->toBe('Connection [nowhere] is not a MySQL connection.') + ->and($service->commands)->toBe([]); +}); + +test('database backup service reports an unwritable temporary directory', function () { + $fixture = database_backups_fixture(); + file_put_contents($fixture['root'] . '/blocker', 'x'); + config(['database-backups.tmp_dir' => $fixture['root'] . '/blocker/tmp']); + + $service = new DatabaseBackupsServiceFake(); + + expect(fn () => $service->dump('primary', 'x.sql.gz')) + ->toThrow(DatabaseBackupException::class, 'Cannot create the backup directory ' . $fixture['root'] . '/blocker/tmp.'); + + config(['database-backups.tmp_dir' => $fixture['root'] . '/tmp']); + mkdir($fixture['root'] . '/tmp/x.sql.gz'); + + expect(fn () => $service->dump('primary', 'x.sql.gz')) + ->toThrow(DatabaseBackupException::class, 'Cannot write ' . $fixture['root'] . '/tmp/x.sql.gz.'); +}); + +test('database backup service wraps a dump client that cannot start or times out', function () { + $fixture = database_backups_fixture(['database-backups.timeout' => 1]); + + $service = new DatabaseBackupsServiceFake(); + $service->script = 'sleep(3);'; + + expect(fn () => $service->dump('primary', 'slow.sql.gz')) + ->toThrow(DatabaseBackupException::class, 'The dump could not run: ') + ->and(file_exists($fixture['root'] . '/tmp/slow.sql.gz'))->toBeFalse(); +}); + +test('database backup service fails a run whose upload does not match the local dump', function () { + database_backups_fixture(['database-backups.notify_on_failure' => false]); + + $disk = DatabaseBackupsDiskFake::at(database_backups_root() . '/disk'); + $disk->reportedSize = 20; + + $service = new DatabaseBackupsServiceFake(); + $service->script = DATABASE_BACKUPS_DUMP_OK; + $service->diskOverride = $disk; + $record = $service->run('manual', ['primary'])[0]; + + expect($record->status)->toBe('failed') + ->and($record->error)->toMatch('/^The uploaded backup is 20 bytes but the local dump is \d+ bytes\.$/'); +}); + +test('database backup service fails a run whose disk is not configured', function () { + database_backups_fixture(['database-backups.disk' => 'missing', 'database-backups.notify_on_failure' => false]); + + $service = new DatabaseBackupsServiceFake(); + $service->script = DATABASE_BACKUPS_DUMP_OK; + + expect($service->run('manual', ['primary'])[0]->error)->toBe('Filesystem disk [missing] is not configured.'); +}); + +test('database backup service applies the bucket override only to s3 disks', function () { + database_backups_fixture(); + + $service = new DatabaseBackupsServiceFake(); + $service->diskOverride = DatabaseBackupsDiskFake::at(database_backups_root() . '/disk'); + $settings = DatabaseBackupSettings::settings(); + + $service->disk(array_merge($settings, ['disk' => 's3', 'bucket' => 'fleetbase-db-backups'])); + $service->disk(array_merge($settings, ['disk' => 's3', 'bucket' => null])); + $service->disk(array_merge($settings, ['bucket' => 'ignored'])); + + expect($service->diskConfigs[0]['bucket'])->toBe('fleetbase-db-backups') + ->and($service->diskConfigs[1]['bucket'])->toBe('fleetbase-media') + ->and($service->diskConfigs[2])->not->toHaveKey('bucket') + ->and($service->objectPath('', 'a.sql.gz'))->toBe('a.sql.gz') + ->and($service->objectPath('/x/y/', 'a.sql.gz'))->toBe('x/y/a.sql.gz'); +}); + +test('database backup retention trims by age and count but always keeps each database newest', function () { + $fixture = database_backups_fixture(['database-backups.retention_days' => 10, 'database-backups.retention_count' => 2]); + Carbon::setTestNow(Carbon::parse('2026-10-06 00:00:00')); + $dir = $fixture['root'] . '/disk/nightly'; + mkdir($dir); + + $files = [ + 'production_fleetbase_backup-20261005-000000.sql.gz', + 'production_fleetbase_backup-20261004-000000.sql.gz', + 'production_fleetbase_backup-20261003-000000.sql.gz', // beyond the count of 2 + 'production_fleetbase_sandbox_backup-20260801-000000.sql.gz', // old, but the newest of its database + 'production_fleetbase_sandbox_backup-20260701-000000.sql', // old + 'notes.txt', + ]; + foreach ($files as $file) { + file_put_contents($dir . '/' . $file, 'x'); + } + database_backups_record(['path' => 'nightly/production_fleetbase_backup-20261003-000000.sql.gz']); + + $service = new DatabaseBackupsServiceFake(); + $deleted = $service->prune(DatabaseBackupSettings::settings()); + sort($deleted); + + expect($deleted)->toBe([ + 'nightly/production_fleetbase_backup-20261003-000000.sql.gz', + 'nightly/production_fleetbase_sandbox_backup-20260701-000000.sql', + ]) + ->and(array_map('basename', glob($dir . '/*')))->toBe([ + 'notes.txt', + 'production_fleetbase_backup-20261004-000000.sql.gz', + 'production_fleetbase_backup-20261005-000000.sql.gz', + 'production_fleetbase_sandbox_backup-20260801-000000.sql.gz', + ]) + ->and(DatabaseBackup::first()->pruned_at)->not->toBeNull() + ->and($service->prune(array_merge(DatabaseBackupSettings::settings(), ['retention_days' => null, 'retention_count' => null])))->toBe([]) + ->and($service->prune(array_merge(DatabaseBackupSettings::settings(), ['retention_count' => null])))->toBe([]); +}); + +test('database backup runs trim after a full success and log a failed trim without failing', function () { + $fixture = database_backups_fixture(); + Carbon::setTestNow(Carbon::parse('2026-10-06 00:00:05')); + mkdir($fixture['root'] . '/disk/nightly'); + file_put_contents($fixture['root'] . '/disk/nightly/production_fleetbase_backup-20260101-000000.sql.gz', 'old'); + + $service = new DatabaseBackupsServiceFake(); + $service->script = DATABASE_BACKUPS_DUMP_OK; + $service->run('scheduled', ['primary']); + + expect(file_exists($fixture['root'] . '/disk/nightly/production_fleetbase_backup-20260101-000000.sql.gz'))->toBeFalse(); + + $disk = DatabaseBackupsDiskFake::at($fixture['root'] . '/disk'); + $disk->listingError = new RuntimeException('listing denied'); + $service->diskOverride = $disk; + + expect($service->run('scheduled', ['primary'])[0]->status)->toBe('completed') + ->and(collect(app('log')->entries)->last())->toBe(['warning', 'Database backup retention failed', ['error' => 'listing denied']]); +}); + +test('database backup service logs a failure notification it could not send', function () { + $fixture = database_backups_fixture(); + $fixture['notifications']->fail = true; + + $service = new DatabaseBackupsServiceFake(); + $service->script = DATABASE_BACKUPS_DUMP_DENIED; + $service->run('manual', ['primary']); + + expect(collect(app('log')->entries)->last())->toBe(['error', 'Could not send the database backup failure notification', ['error' => 'mail is down']]); +}); + +test('database backup service never runs two backups at once', function () { + database_backups_fixture(); + + $service = new DatabaseBackupsServiceFake(); + $held = app('cache')->lock(DatabaseBackupService::LOCK_KEY, 60); + $held->get(); + + expect(fn () => $service->run('manual'))->toThrow(DatabaseBackupException::class, 'Another database backup is already running.'); + + $held->release(); + $service->script = DATABASE_BACKUPS_DUMP_OK; + $service->run('manual', ['primary']); + + expect(app('cache')->lock(DatabaseBackupService::LOCK_KEY, 60)->get())->toBeTrue(); +}); + +test('database backup service runs unlocked when the cache cannot lock', function () { + database_backups_fixture(); + app()->instance('cache', new class { + public function lock() + { + throw new BadMethodCallException('no locks'); + } + }); + Facade::clearResolvedInstance('cache'); + + $service = new DatabaseBackupsServiceFake(); + $service->script = DATABASE_BACKUPS_DUMP_OK; + + expect($service->run('manual', ['primary'])[0]->status)->toBe('completed') + ->and($service->run('manual', []))->toHaveCount(2); +}); + +// --------------------------------------------------------------------------------------- +// Command, job, notification, model +// --------------------------------------------------------------------------------------- + +test('db backup command reports each database and exits non zero on any failure', function () { + database_backups_fixture(); + + $ok = database_backups_record(['path' => 'nightly/a.sql.gz', 'size_bytes' => 2048, 'duration_ms' => 15]); + $failed = database_backups_record(['database' => 'fleetbase_sandbox', 'status' => 'failed', 'error' => 'Access denied']); + + [$code, $display] = database_backups_command($service = new DatabaseBackupsServiceStub([$ok]), ['--trigger' => 'scheduled', '--connection' => ['primary']]); + expect($code)->toBe(0) + ->and($display)->toContain('Backed up fleetbase to backups:nightly/a.sql.gz (2048 bytes, 15 ms)') + ->and($service->calls)->toBe([['scheduled', ['primary']]]); + + [$code, $display] = database_backups_command($service = new DatabaseBackupsServiceStub([$ok, $failed]), ['--trigger' => 'bogus']); + expect($code)->toBe(1) + ->and($display)->toContain('Backup of fleetbase_sandbox failed: Access denied') + ->and($service->calls)->toBe([['console', null]]); + + [$code, $display] = database_backups_command(new DatabaseBackupsServiceStub([])); + expect($code)->toBe(1)->and($display)->toContain('No database connections are configured for backup.'); + + [$code, $display] = database_backups_command(new DatabaseBackupsServiceStub(new DatabaseBackupException('Another database backup is already running.'))); + expect($code)->toBe(1)->and($display)->toContain('Another database backup is already running.'); +}); + +test('db backup command does nothing while disabled unless forced', function () { + database_backups_fixture(['database-backups.enabled' => false]); + + [$code, $display] = database_backups_command($service = new DatabaseBackupsServiceStub([])); + expect($code)->toBe(0) + ->and($display)->toContain('Database backups are disabled.') + ->and($service->calls)->toBe([]); + + [$code] = database_backups_command($service = new DatabaseBackupsServiceStub([database_backups_record([])]), ['--force' => true]); + expect($code)->toBe(0)->and($service->calls)->toHaveCount(1); +}); + +test('run database backup job backs up as a manual run and logs a run that could not start', function () { + database_backups_fixture(); + + $job = new RunDatabaseBackup(); + $job->handle($service = new DatabaseBackupsServiceStub([])); + + expect($service->calls)->toBe([['manual', null]]) + ->and($job->tries)->toBe(1); + + $job->handle(new DatabaseBackupsServiceStub(new DatabaseBackupException('Another database backup is already running.'))); + + expect(collect(app('log')->entries)->last())->toBe(['warning', 'Requested database backup did not run', ['error' => 'Another database backup is already running.']]); +}); + +test('database backup failed notification mails each failure with a link to the console', function () { + database_backups_fixture(); + + $notification = new DatabaseBackupFailed([ + database_backups_record(['status' => 'failed', 'error' => 'Access denied']), + database_backups_record(['connection_name' => 'sandbox', 'database' => 'fleetbase_sandbox', 'status' => 'failed', 'error' => null]), + ]); + $mail = $notification->toMail(new AnonymousNotifiable()); + + expect($notification->via(null))->toBe(['mail']) + ->and($mail->subject)->toBe('Fleetbase database backup failed') + ->and($mail->level)->toBe('error') + ->and($mail->introLines)->toContain('fleetbase (primary): Access denied') + ->and($mail->introLines)->toContain('fleetbase_sandbox (sandbox): unknown error') + ->and($mail->actionUrl)->toContain('admin/database-backups') + ->and($notification->toArray(null)['failures'])->toHaveCount(2); +}); + +test('database backup records expose the admin shape and prune after a year', function () { + database_backups_fixture(); + Carbon::setTestNow(Carbon::parse('2026-10-06 12:00:00')); + + $record = database_backups_record(['path' => 'a.sql.gz', 'size_bytes' => 10, 'duration_ms' => 5, 'completed_at' => now(), 'pruned_at' => null]); + database_backups_record(['started_at' => now()->subYears(2)]); + + expect($record->uuid)->toBeString()->toHaveLength(36) + ->and($record->getConnectionName())->toBe('mysql') + ->and($record->toAdminArray())->toBe([ + 'id' => $record->uuid, + 'connection' => 'primary', + 'database' => 'fleetbase', + 'status' => 'completed', + 'trigger' => 'scheduled', + 'disk' => 'backups', + 'path' => 'a.sql.gz', + 'size_bytes' => 10, + 'duration_ms' => 5, + 'error' => null, + 'started_at' => '2026-10-06T12:00:00+00:00', + 'completed_at' => '2026-10-06T12:00:00+00:00', + 'pruned_at' => null, + ]) + ->and($record->prunable()->count())->toBe(1); +}); + +// --------------------------------------------------------------------------------------- +// Admin controller +// --------------------------------------------------------------------------------------- + +test('database backup controller returns settings with defaults, choices and the latest runs', function () { + database_backups_fixture(); + Carbon::setTestNow(Carbon::parse('2026-10-06 12:00:00')); + + $controller = new DatabaseBackupController(); + $empty = $controller->getSettings(AdminRequest::create('/int/v1/database-backups/settings'))->getData(true); + + expect($empty['last_run'])->toBeNull() + ->and($empty['last_success'])->toBeNull() + ->and($empty['disks'])->toBe(DatabaseBackupSettings::disks()) + ->and($empty['connections'])->toBe(['primary', 'sandbox']) + ->and($empty['settings'])->toBe(DatabaseBackupSettings::defaults()) + ->and($empty['defaults'])->toBe(DatabaseBackupSettings::defaults()); + + $success = database_backups_record(['started_at' => now()->subDay()]); + $failure = database_backups_record(['status' => 'failed', 'started_at' => now()]); + $payload = $controller->getSettings(AdminRequest::create('/int/v1/database-backups/settings'))->getData(true); + + expect($payload['last_run']['id'])->toBe($failure->uuid) + ->and($payload['last_success']['id'])->toBe($success->uuid); +}); + +test('database backup controller saves validated settings and resets them', function () { + database_backups_fixture(); + $controller = new DatabaseBackupController(); + $input = [ + 'enabled' => true, + 'frequency' => 'every_six_hours', + 'time' => '01:15', + 'day_of_week' => 1, + 'disk' => 's3', + 'bucket' => 'fleetbase-db-backups', + 'path' => 'mysql', + 'connections' => ['primary'], + 'retention_days' => 14, + 'retention_count' => null, + 'min_size_bytes' => 2048, + 'notify_on_failure' => true, + 'notify_emails' => ['ops@example.test', 'cto@example.test'], + ]; + + $saved = $controller->saveSettings(AdminRequest::create('/int/v1/database-backups/settings', 'POST', $input))->getData(true); + + expect($saved['settings'])->toBe(array_merge($input, ['day_of_week' => 1])) + ->and(DatabaseBackupSettings::settings()['frequency'])->toBe('every_six_hours'); + + $reset = $controller->resetSettings(AdminRequest::create('/int/v1/database-backups/settings', 'DELETE'))->getData(true); + expect($reset['settings'])->toBe(DatabaseBackupSettings::defaults()); +}); + +test('database backup controller rejects invalid settings', function (array $override) { + database_backups_fixture(); + $input = array_merge([ + 'enabled' => true, + 'frequency' => 'daily', + 'time' => '00:00', + 'disk' => 'backups', + 'connections' => ['primary'], + ], $override); + + expect(fn () => (new DatabaseBackupController())->saveSettings(AdminRequest::create('/int/v1/database-backups/settings', 'POST', $input))) + ->toThrow(ValidationException::class); +})->with([ + 'unknown frequency' => [['frequency' => 'monthly']], + 'bad time' => [['time' => '7pm']], + 'unknown disk' => [['disk' => 'nowhere']], + 'non mysql database' => [['connections' => ['mysql']]], + 'no databases' => [['connections' => []]], + 'bad email' => [['notify_emails' => ['not-an-email']]], + 'zero retention' => [['retention_days' => 0]], +]); + +test('database backup controller lists recent runs newest first', function () { + database_backups_fixture(); + Carbon::setTestNow(Carbon::parse('2026-10-06 12:00:00')); + + $older = database_backups_record(['started_at' => now()->subHours(2)]); + $newer = database_backups_record(['started_at' => now()->subHour()]); + database_backups_record(['started_at' => now()->subHours(3)]); + + $controller = new DatabaseBackupController(); + $all = $controller->runs(AdminRequest::create('/int/v1/database-backups/runs'))->getData(true); + $limited = $controller->runs(AdminRequest::create('/int/v1/database-backups/runs', 'GET', ['limit' => 2]))->getData(true); + + expect(array_column($all['runs'], 'id'))->toHaveCount(3) + ->and(array_column($limited['runs'], 'id'))->toBe([$newer->uuid, $older->uuid]); + + expect(fn () => $controller->runs(AdminRequest::create('/int/v1/database-backups/runs', 'GET', ['limit' => 0]))) + ->toThrow(ValidationException::class); +}); + +test('database backup controller queues a manual run', function () { + $fixture = database_backups_fixture(); + + $response = (new DatabaseBackupController())->run(AdminRequest::create('/int/v1/database-backups/run', 'POST')); + + expect($response->getStatusCode())->toBe(202) + ->and($response->getData(true))->toBe(['status' => 'queued']) + ->and($fixture['bus']->dispatched)->toHaveCount(1) + ->and($fixture['bus']->dispatched[0])->toBeInstanceOf(RunDatabaseBackup::class) + ->and($fixture['bus']->dispatched[0]->trigger)->toBe('manual'); +}); diff --git a/tests/Unit/RoutesContractTest.php b/tests/Unit/RoutesContractTest.php index f7e8fe2e..9c6eefb6 100644 --- a/tests/Unit/RoutesContractTest.php +++ b/tests/Unit/RoutesContractTest.php @@ -385,4 +385,25 @@ function routes_contract_index(array $rows, string $method, string $uri): int|fa ->and($route['middleware'])->toContain('fleetbase.protected'); } }); + + test('route file exposes database backup administration as protected routes', function () { + $routes = routes_contract_rows(routes_contract_router()); + $controller = 'Fleetbase\\Http\\Controllers\\Internal\\v1\\DatabaseBackupController'; + + $expected = [ + ['GET', 'int/v1/database-backups/settings', 'getSettings'], + ['POST', 'int/v1/database-backups/settings', 'saveSettings'], + ['DELETE', 'int/v1/database-backups/settings', 'resetSettings'], + ['GET', 'int/v1/database-backups/runs', 'runs'], + ['POST', 'int/v1/database-backups/run', 'run'], + ]; + + foreach ($expected as [$method, $uri, $action]) { + $route = routes_contract_find($routes, $method, $uri); + + expect($route)->not->toBeNull() + ->and($route['action'])->toBe($controller . '@' . $action) + ->and($route['middleware'])->toContain('fleetbase.protected'); + } + }); } From 710839295080c1404335f00c106341e951512f92 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 13:20:57 +0800 Subject: [PATCH 03/51] test(backups): align operators for php-cs-fixer --- tests/Unit/DatabaseBackupsTest.php | 82 ++++++++++++++++-------------- 1 file changed, 44 insertions(+), 38 deletions(-) diff --git a/tests/Unit/DatabaseBackupsTest.php b/tests/Unit/DatabaseBackupsTest.php index 22a123f2..820ac9b2 100644 --- a/tests/Unit/DatabaseBackupsTest.php +++ b/tests/Unit/DatabaseBackupsTest.php @@ -240,38 +240,39 @@ function database_backups_fixture(array $config = []): array mkdir($root . '/tmp', 0777, true); $sqlite = ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']; + $disks = [ + 'backups' => ['driver' => 'local', 'root' => $root . '/disk'], + 's3' => ['driver' => 's3', 'bucket' => 'fleetbase-media', 'region' => 'ap-southeast-1'], + ]; $container = bind_test_container(array_merge([ - 'app.name' => 'Fleetbase', - 'app.env' => 'production', - 'fleetbase.console.host' => 'https://console.fleetbase.test', - 'database.default' => 'mysql', - 'database.connections.mysql' => $sqlite, - 'database.connections.primary' => ['driver' => 'mysql', 'host' => 'db.example.test', 'port' => 3307, 'username' => 'fleetbase', 'password' => 'secret value', 'database' => 'fleetbase'], - 'database.connections.sandbox' => ['driver' => 'mysql', 'unix_socket' => '/run/mysqld.sock', 'username' => 'fleetbase', 'password' => 'sandbox secret', 'database' => 'fleetbase_sandbox'], - 'fleetbase.connection.db' => 'mysql', - 'filesystems.disks' => [ - 'backups' => ['driver' => 'local', 'root' => $root . '/disk'], - 's3' => ['driver' => 's3', 'bucket' => 'fleetbase-media', 'region' => 'ap-southeast-1'], - ], - 'database-backups.enabled' => true, - 'database-backups.frequency' => 'daily', - 'database-backups.time' => '02:30', - 'database-backups.day_of_week' => 0, - 'database-backups.disk' => 'backups', - 'database-backups.bucket' => null, - 'database-backups.path' => 'nightly', - 'database-backups.connections' => ['primary', 'sandbox'], - 'database-backups.retention_days' => 30, - 'database-backups.retention_count' => null, - 'database-backups.min_size_bytes' => 1024, - 'database-backups.notify_on_failure' => true, - 'database-backups.notify_emails' => ['ops@example.test'], - 'database-backups.dump_binary' => 'mysqldump', - 'database-backups.dump_args' => ['--single-transaction', '--no-tablespaces'], - 'database-backups.extra_dump_args' => ['--column-statistics=0'], - 'database-backups.timeout' => 60, - 'database-backups.tmp_dir' => $root . '/tmp', + 'app.name' => 'Fleetbase', + 'app.env' => 'production', + 'fleetbase.console.host' => 'https://console.fleetbase.test', + 'database.default' => 'mysql', + 'database.connections.mysql' => $sqlite, + 'database.connections.primary' => ['driver' => 'mysql', 'host' => 'db.example.test', 'port' => 3307, 'username' => 'fleetbase', 'password' => 'secret value', 'database' => 'fleetbase'], + 'database.connections.sandbox' => ['driver' => 'mysql', 'unix_socket' => '/run/mysqld.sock', 'username' => 'fleetbase', 'password' => 'sandbox secret', 'database' => 'fleetbase_sandbox'], + 'fleetbase.connection.db' => 'mysql', + 'filesystems.disks' => $disks, + 'database-backups.enabled' => true, + 'database-backups.frequency' => 'daily', + 'database-backups.time' => '02:30', + 'database-backups.day_of_week' => 0, + 'database-backups.disk' => 'backups', + 'database-backups.bucket' => null, + 'database-backups.path' => 'nightly', + 'database-backups.connections' => ['primary', 'sandbox'], + 'database-backups.retention_days' => 30, + 'database-backups.retention_count' => null, + 'database-backups.min_size_bytes' => 1024, + 'database-backups.notify_on_failure' => true, + 'database-backups.notify_emails' => ['ops@example.test'], + 'database-backups.dump_binary' => 'mysqldump', + 'database-backups.dump_args' => ['--single-transaction', '--no-tablespaces'], + 'database-backups.extra_dump_args' => ['--column-statistics=0'], + 'database-backups.timeout' => 60, + 'database-backups.tmp_dir' => $root . '/tmp', ], $config)); $container->instance('cache', new CacheRepository(new ArrayStore())); @@ -680,8 +681,8 @@ function database_backups_command(DatabaseBackupService $service, array $input = expect(file_exists($fixture['root'] . '/disk/nightly/production_fleetbase_backup-20260101-000000.sql.gz'))->toBeFalse(); - $disk = DatabaseBackupsDiskFake::at($fixture['root'] . '/disk'); - $disk->listingError = new RuntimeException('listing denied'); + $disk = DatabaseBackupsDiskFake::at($fixture['root'] . '/disk'); + $disk->listingError = new RuntimeException('listing denied'); $service->diskOverride = $disk; expect($service->run('scheduled', ['primary'])[0]->status)->toBe('completed') @@ -742,12 +743,14 @@ public function lock() $ok = database_backups_record(['path' => 'nightly/a.sql.gz', 'size_bytes' => 2048, 'duration_ms' => 15]); $failed = database_backups_record(['database' => 'fleetbase_sandbox', 'status' => 'failed', 'error' => 'Access denied']); - [$code, $display] = database_backups_command($service = new DatabaseBackupsServiceStub([$ok]), ['--trigger' => 'scheduled', '--connection' => ['primary']]); + $service = new DatabaseBackupsServiceStub([$ok]); + [$code, $display] = database_backups_command($service, ['--trigger' => 'scheduled', '--connection' => ['primary']]); expect($code)->toBe(0) ->and($display)->toContain('Backed up fleetbase to backups:nightly/a.sql.gz (2048 bytes, 15 ms)') ->and($service->calls)->toBe([['scheduled', ['primary']]]); - [$code, $display] = database_backups_command($service = new DatabaseBackupsServiceStub([$ok, $failed]), ['--trigger' => 'bogus']); + $service = new DatabaseBackupsServiceStub([$ok, $failed]); + [$code, $display] = database_backups_command($service, ['--trigger' => 'bogus']); expect($code)->toBe(1) ->and($display)->toContain('Backup of fleetbase_sandbox failed: Access denied') ->and($service->calls)->toBe([['console', null]]); @@ -762,20 +765,23 @@ public function lock() test('db backup command does nothing while disabled unless forced', function () { database_backups_fixture(['database-backups.enabled' => false]); - [$code, $display] = database_backups_command($service = new DatabaseBackupsServiceStub([])); + $service = new DatabaseBackupsServiceStub([]); + [$code, $display] = database_backups_command($service); expect($code)->toBe(0) ->and($display)->toContain('Database backups are disabled.') ->and($service->calls)->toBe([]); - [$code] = database_backups_command($service = new DatabaseBackupsServiceStub([database_backups_record([])]), ['--force' => true]); + $service = new DatabaseBackupsServiceStub([database_backups_record([])]); + [$code] = database_backups_command($service, ['--force' => true]); expect($code)->toBe(0)->and($service->calls)->toHaveCount(1); }); test('run database backup job backs up as a manual run and logs a run that could not start', function () { database_backups_fixture(); - $job = new RunDatabaseBackup(); - $job->handle($service = new DatabaseBackupsServiceStub([])); + $job = new RunDatabaseBackup(); + $service = new DatabaseBackupsServiceStub([]); + $job->handle($service); expect($service->calls)->toBe([['manual', null]]) ->and($job->tries)->toBe(1); From 81b024520ec84dbd707389df8d2b7f927d47f3e8 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 14:31:31 +0800 Subject: [PATCH 04/51] feat(verification): hashed one-time codes with attempt counting Add VerificationCode::issue(), check() and attemptsLeft() for flows where a leaked table must not give away live codes: - issue() stores an HMAC of the code, keyed by the app key, and hands the plain code back once on the instance (plainCode), defaulting to a 10-minute expiry and an 'active' status. - check() compares with hash_equals, counts wrong attempts in meta and locks the code on the last allowed one. Expired and locked codes report as such. - The creating hook keeps a code that was already set, so issue() is not overwritten; codes made the old way still get a random one and still check. Existing generators and their callers are unchanged. First user: the FleetOps public tracking page's one-time codes. --- src/Models/VerificationCode.php | 100 +++++++++++++++++- .../Unit/Models/VerificationCodeModelTest.php | 69 ++++++++++++ 2 files changed, 167 insertions(+), 2 deletions(-) diff --git a/src/Models/VerificationCode.php b/src/Models/VerificationCode.php index c7b45b63..d7789dc3 100644 --- a/src/Models/VerificationCode.php +++ b/src/Models/VerificationCode.php @@ -60,15 +60,111 @@ class VerificationCode extends Model */ protected $hidden = []; - /** on boot generate code */ + /** + * Outcomes of {@see check()}. + */ + public const CHECK_VALID = 'valid'; + public const CHECK_INVALID = 'invalid'; + public const CHECK_EXPIRED = 'expired'; + public const CHECK_LOCKED = 'locked'; + + /** + * The plain code of a code made by {@see issue()}. It lives on this instance only, so the + * caller can send it once; the database keeps an HMAC of it. + */ + public ?string $plainCode = null; + + /** on boot generate code, unless one was set already (a hashed code from {@see issue()}) */ public static function boot() { parent::boot(); static::creating(function ($model) { - $model->code = random_int(100000, 999999); + if (blank($model->code)) { + $model->code = random_int(100000, 999999); + } }); } + /** + * Issue a code that is stored hashed, for flows where a leaked table must not give away + * live codes. The plain code is on the returned instance's `plainCode`; sending it is up + * to the caller. + * + * Options: `expireAfter` (default 10 minutes from now), `meta` (merged into the code's meta) + * and `status` (default 'active'). + * + * @param mixed $subject the model the code is for, or null + */ + public static function issue($subject, string $for, array $options = []): static + { + $plainCode = (string) random_int(100000, 999999); + + $verifyCode = new static(); + $verifyCode->for = $for; + $verifyCode->status = data_get($options, 'status', 'active'); + $verifyCode->expires_at = data_get($options, 'expireAfter', Carbon::now()->addMinutes(10)); + $verifyCode->code = static::hashCode($plainCode); + $verifyCode->meta = array_merge((array) data_get($options, 'meta', []), ['hashed' => true, 'attempts' => 0]); + + if ($subject) { + $verifyCode->setSubject($subject, false); + } + + $verifyCode->save(); + $verifyCode->plainCode = $plainCode; + + return $verifyCode; + } + + /** + * The HMAC a hashed code is stored as, keyed by the app key. + */ + public static function hashCode(string $plainCode): string + { + return hash_hmac('sha256', $plainCode, (string) config('app.key', '')); + } + + /** + * Check a plain code against this one. A wrong code counts an attempt, and the code locks + * itself on the last allowed attempt, so it can't be guessed further. + * + * Read the code without the expiry scope to tell an expired code apart: the scope hides + * expired rows from queries. + */ + public function check(string $plainCode, int $maxAttempts = 3): string + { + if ($this->status === 'locked') { + return self::CHECK_LOCKED; + } + + if ($this->hasExpired()) { + return self::CHECK_EXPIRED; + } + + $plainCode = trim($plainCode); + $expected = $this->getMeta('hashed') === true ? static::hashCode($plainCode) : $plainCode; + if (hash_equals((string) $this->code, $expected)) { + return self::CHECK_VALID; + } + + $attempts = (int) $this->getMeta('attempts', 0) + 1; + $this->setMeta('attempts', $attempts); + if ($attempts >= $maxAttempts) { + $this->status = 'locked'; + } + $this->save(); + + return $this->status === 'locked' ? self::CHECK_LOCKED : self::CHECK_INVALID; + } + + /** + * How many wrong codes {@see check()} still allows. + */ + public function attemptsLeft(int $maxAttempts = 3): int + { + return max(0, $maxAttempts - (int) $this->getMeta('attempts', 0)); + } + /** * @return \Illuminate\Database\Eloquent\Relations\MorphTo */ diff --git a/tests/Unit/Models/VerificationCodeModelTest.php b/tests/Unit/Models/VerificationCodeModelTest.php index 1add9a42..eca78d5f 100644 --- a/tests/Unit/Models/VerificationCodeModelTest.php +++ b/tests/Unit/Models/VerificationCodeModelTest.php @@ -506,3 +506,72 @@ public function message(): never expect(VerificationCode::query()->count())->toBe(0); }); + +it('issues hashed codes that keep only an hmac of the plain code', function () { + verification_code_model_database(); + config(['app.key' => 'base64:test-app-key']); + Carbon::setTestNow(Carbon::parse('2026-10-06 09:00:00', 'UTC')); + + $subject = verification_code_subject(['uuid' => 'contact-1']); + $issued = VerificationCode::issue($subject, 'fleetops_tracking_access', ['meta' => ['scope' => 'order-1']]); + $stored = VerificationCode::query()->whereKey($issued->uuid)->first(); + + expect($issued->plainCode)->toMatch('/^[1-9][0-9]{5}$/') + ->and($stored->code)->toBe(hash_hmac('sha256', $issued->plainCode, 'base64:test-app-key')) + ->and($stored->code)->not->toBe($issued->plainCode) + ->and(VerificationCode::hashCode($issued->plainCode))->toBe($stored->code) + ->and($stored->for)->toBe('fleetops_tracking_access') + ->and($stored->status)->toBe('active') + ->and($stored->subject_uuid)->toBe('contact-1') + ->and($stored->expires_at->toDateTimeString())->toBe('2026-10-06 09:10:00') + ->and($stored->meta)->toBe(['scope' => 'order-1', 'hashed' => true, 'attempts' => 0]) + ->and($stored->plainCode)->toBeNull(); + + $custom = VerificationCode::issue(null, 'other', [ + 'expireAfter' => Carbon::parse('2026-10-06 09:30:00', 'UTC'), + 'status' => 'pending', + ]); + + expect($custom->status)->toBe('pending') + ->and($custom->subject_uuid)->toBeNull() + ->and($custom->expires_at->toDateTimeString())->toBe('2026-10-06 09:30:00') + ->and($custom->meta)->toBe(['hashed' => true, 'attempts' => 0]); +}); + +it('checks hashed codes, counts wrong attempts and locks on the last one', function () { + verification_code_model_database(); + config(['app.key' => 'base64:test-app-key']); + + $issued = VerificationCode::issue(verification_code_subject(), 'fleetops_tracking_access'); + $wrong = $issued->plainCode === '111111' ? '222222' : '111111'; + + expect($issued->check($wrong))->toBe(VerificationCode::CHECK_INVALID) + ->and($issued->attemptsLeft())->toBe(2) + ->and($issued->check(' ' . $issued->plainCode . ' '))->toBe(VerificationCode::CHECK_VALID) + ->and($issued->check($wrong))->toBe(VerificationCode::CHECK_INVALID) + ->and($issued->check($wrong))->toBe(VerificationCode::CHECK_LOCKED) + ->and($issued->attemptsLeft())->toBe(0) + ->and($issued->check($issued->plainCode))->toBe(VerificationCode::CHECK_LOCKED) + ->and(VerificationCode::query()->whereKey($issued->uuid)->first()->status)->toBe('locked') + ->and(VerificationCode::query()->whereKey($issued->uuid)->first()->getMeta('attempts'))->toBe(3); + + $single = VerificationCode::issue(null, 'fleetops_tracking_access'); + expect($single->check($wrong, 1))->toBe(VerificationCode::CHECK_LOCKED); +}); + +it('reports expired codes and still checks plain codes made the old way', function () { + verification_code_model_database(); + config(['app.key' => 'base64:test-app-key']); + Carbon::setTestNow(Carbon::parse('2026-10-06 09:00:00', 'UTC')); + + $issued = VerificationCode::issue(null, 'fleetops_tracking_access'); + Carbon::setTestNow(Carbon::parse('2026-10-06 09:10:00', 'UTC')); + + expect($issued->check($issued->plainCode))->toBe(VerificationCode::CHECK_EXPIRED); + + Carbon::setTestNow(); + $plain = VerificationCode::generateFor(null, 'device_pairing'); + + expect($plain->check((string) $plain->code))->toBe(VerificationCode::CHECK_VALID) + ->and($plain->check('000000'))->toBe(VerificationCode::CHECK_INVALID); +}); From c8eae51a73b687f6ab262dc602f4364bab0c440b Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 15:01:29 +0800 Subject: [PATCH 05/51] feat(socket-auth): socket tokens, principals and channel authorization Adds the realtime channel authentication core, switched on by SOCKETCLUSTER_AUTH_KEY (config auth_key, publish_url, token_ttl): - SocketToken mints and verifies HS256 socket tokens (iss/aud/iat/nbf/exp/jti plus kind, sub, cid, cpid, env, ids, adm, scp, sid); anything not HS256 with the configured key, or with a wrong issuer/audience or out-of-range time claims, is rejected. Includes the scoped public tracking token. - SocketSignature derives per-purpose HMAC keys and signs/verifies the timestamped requests exchanged with the socket server. - SocketPrincipal, ChannelDecision, the SocketChannelResolver contract and the SocketChannelRegistry extensions register their channel prefixes with. - ChannelAuthorizer applies install, expiry, scope, system and self rules, then the prefix resolver, caching decisions per token and channel. - Core resolvers for company, api, user, test, install/uninstall, chat, chat_channel, chat_participant, chat_message and file channels. - The registry and authorizer are container singletons. --- config/broadcasting.connections.php | 6 + src/Contracts/SocketChannelResolver.php | 17 + .../SocketClusterServiceProvider.php | 25 ++ .../SocketCluster/ChannelAuthorizer.php | 154 +++++++ src/Support/SocketCluster/ChannelDecision.php | 52 +++ .../SocketCluster/CoreChannelResolvers.php | 149 +++++++ .../SocketCluster/ModelChannelResolver.php | 64 +++ .../SocketCluster/SocketChannelRegistry.php | 79 ++++ src/Support/SocketCluster/SocketPrincipal.php | 199 +++++++++ src/Support/SocketCluster/SocketSignature.php | 77 ++++ src/Support/SocketCluster/SocketToken.php | 214 ++++++++++ tests/Fixtures/Support/SocketAuthFixtures.php | 324 +++++++++++++++ .../Providers/CoreProviderContractsTest.php | 20 + .../SocketChannelAuthorizationTest.php | 372 +++++++++++++++++ tests/Unit/Support/SocketTokenTest.php | 381 ++++++++++++++++++ 15 files changed, 2133 insertions(+) create mode 100644 src/Contracts/SocketChannelResolver.php create mode 100644 src/Support/SocketCluster/ChannelAuthorizer.php create mode 100644 src/Support/SocketCluster/ChannelDecision.php create mode 100644 src/Support/SocketCluster/CoreChannelResolvers.php create mode 100644 src/Support/SocketCluster/ModelChannelResolver.php create mode 100644 src/Support/SocketCluster/SocketChannelRegistry.php create mode 100644 src/Support/SocketCluster/SocketPrincipal.php create mode 100644 src/Support/SocketCluster/SocketSignature.php create mode 100644 src/Support/SocketCluster/SocketToken.php create mode 100644 tests/Fixtures/Support/SocketAuthFixtures.php create mode 100644 tests/Unit/Support/SocketChannelAuthorizationTest.php create mode 100644 tests/Unit/Support/SocketTokenTest.php diff --git a/config/broadcasting.connections.php b/config/broadcasting.connections.php index bfe48c88..214514e3 100644 --- a/config/broadcasting.connections.php +++ b/config/broadcasting.connections.php @@ -23,6 +23,12 @@ 'path' => env('SOCKETCLUSTER_PATH', '/socketcluster/'), 'query' => [], ], + + // Realtime channel authentication. Leaving SOCKETCLUSTER_AUTH_KEY unset keeps the + // feature off: no socket tokens are minted and broadcasts use the websocket publisher. + 'auth_key' => env('SOCKETCLUSTER_AUTH_KEY'), + 'publish_url' => env('SOCKETCLUSTER_PUBLISH_URL', 'http://' . env('SOCKETCLUSTER_HOST', 'socket') . ':8001'), + 'token_ttl' => (int) env('SOCKETCLUSTER_TOKEN_TTL', 900), ], // for apple apn diff --git a/src/Contracts/SocketChannelResolver.php b/src/Contracts/SocketChannelResolver.php new file mode 100644 index 00000000..198e346b --- /dev/null +++ b/src/Contracts/SocketChannelResolver.php @@ -0,0 +1,17 @@ +app->singleton(SocketChannelRegistry::class, function () { + $registry = new SocketChannelRegistry(); + CoreChannelResolvers::register($registry); + + return $registry; + }); + + $this->app->singleton(ChannelAuthorizer::class, function ($app) { + return new ChannelAuthorizer($app->make(SocketChannelRegistry::class)); + }); + } + /** * Register new BroadcastManager in boot. * diff --git a/src/Support/SocketCluster/ChannelAuthorizer.php b/src/Support/SocketCluster/ChannelAuthorizer.php new file mode 100644 index 00000000..5c0e7d34 --- /dev/null +++ b/src/Support/SocketCluster/ChannelAuthorizer.php @@ -0,0 +1,154 @@ +instanceHasUsers() + ? ChannelDecision::allowed('install_pending', ChannelDecision::DENY_TTL) + : ChannelDecision::denied('no_token'); + } + + $remaining = $principal->secondsRemaining(); + + if ($remaining !== null && $remaining <= 0) { + return ChannelDecision::denied('expired'); + } + + $cacheKey = $principal->jti === null ? null : self::CACHE_PREFIX . sha1($principal->jti . '|' . $channel); + $cached = $cacheKey === null ? null : Cache::get($cacheKey); + + if (is_array($cached)) { + return ChannelDecision::fromArray($cached); + } + + $decision = $this->decide($principal, $channel); + + if ($decision->allow && $remaining !== null) { + $decision = $decision->capTtl($remaining); + } + + if ($cacheKey !== null) { + Cache::put($cacheKey, $decision->toArray(), $decision->ttl); + } + + return $decision; + } + + /** + * A channel name the socket server accepts: non-empty, at most 255 characters, no whitespace. + */ + public static function isValidChannel(string $channel): bool + { + return $channel !== '' && strlen($channel) <= self::MAX_CHANNEL_LENGTH && !preg_match('/\s/', $channel); + } + + /** + * The channels a principal may always follow without a lookup. + */ + public static function isSelfChannel(SocketPrincipal $principal, string $channel): bool + { + $own = []; + + if ($principal->isCompanyScoped()) { + $own[] = 'company.' . $principal->cid; + $own[] = 'company.' . $principal->cpid; + } + + if ($principal->kind === 'api') { + $own[] = 'api.' . $principal->sub; + } + + foreach ($principal->ids as $id) { + $own[] = 'user.' . $id; + $own[] = 'driver.' . $id; + } + + // Empty ids would yield names like "company." which no real channel has. + if (in_array($channel, array_filter($own, fn ($name) => !str_ends_with($name, '.')), true)) { + return true; + } + + return $principal->kind === 'user' + && $principal->cid !== null + && (str_starts_with($channel, 'install.' . $principal->cid . '.') || str_starts_with($channel, 'uninstall.' . $principal->cid . '.')); + } + + protected function decide(SocketPrincipal $principal, string $channel): ChannelDecision + { + if ($principal->scp !== null) { + return in_array($channel, $principal->scp, true) ? ChannelDecision::allowed('scope') : ChannelDecision::denied('out_of_scope'); + } + + if ($principal->isSystem()) { + return ChannelDecision::allowed('system'); + } + + if (static::isSelfChannel($principal, $channel)) { + return ChannelDecision::allowed('self'); + } + + $separator = strpos($channel, '.'); + $prefix = $separator === false ? $channel : substr($channel, 0, $separator); + $id = $separator === false ? '' : substr($channel, $separator + 1); + $resolver = $this->registry->resolve($prefix); + + if ($resolver === null || $id === '') { + return ChannelDecision::denied('unknown_prefix'); + } + + try { + $allowed = $resolver instanceof SocketChannelResolver + ? $resolver->authorize($principal, $id, $channel) + : $resolver($principal, $id, $channel); + } catch (\Throwable $e) { + Log::warning('Socket channel resolver failed.', ['prefix' => $prefix, 'error' => $e->getMessage()]); + + return ChannelDecision::denied('resolver_error'); + } + + return $allowed ? ChannelDecision::allowed('resolver') : ChannelDecision::denied('forbidden'); + } + + /** + * Whether setup has created a user yet. An unreachable or unmigrated database counts as not yet. + */ + protected function instanceHasUsers(): bool + { + try { + return User::query()->exists(); + } catch (\Throwable $e) { + return false; + } + } +} diff --git a/src/Support/SocketCluster/ChannelDecision.php b/src/Support/SocketCluster/ChannelDecision.php new file mode 100644 index 00000000..1de9b555 --- /dev/null +++ b/src/Support/SocketCluster/ChannelDecision.php @@ -0,0 +1,52 @@ +allow, max(1, min($this->ttl, $seconds)), $this->reason); + } + + public function toArray(): array + { + return [ + 'allow' => $this->allow, + 'ttl' => $this->ttl, + 'reason' => $this->reason, + ]; + } +} diff --git a/src/Support/SocketCluster/CoreChannelResolvers.php b/src/Support/SocketCluster/CoreChannelResolvers.php new file mode 100644 index 00000000..ac8c9233 --- /dev/null +++ b/src/Support/SocketCluster/CoreChannelResolvers.php @@ -0,0 +1,149 @@ +register('company', [static::class, 'company']); + $registry->register('api', [static::class, 'api']); + $registry->register('user', [static::class, 'user']); + $registry->register('test', [static::class, 'test']); + $registry->register('install', [static::class, 'install']); + $registry->register('uninstall', [static::class, 'install']); + $registry->registerModel('chat', ChatChannel::class, [static::class, 'participatesInChannel']); + $registry->registerModel('chat_channel', ChatChannel::class, [static::class, 'participatesInChannel']); + $registry->registerModel('chat_participant', ChatParticipant::class, [static::class, 'isParticipant']); + $registry->registerModel('chat_message', ChatMessage::class, [static::class, 'participatesInMessage']); + $registry->registerModel('file', File::class); + } + + /** + * `company.{uuid|public_id}`: the principal's own company. + */ + public static function company(SocketPrincipal $principal, string $id): bool + { + if (!$principal->isCompanyScoped() || $principal->cid === null) { + return false; + } + + $company = ModelChannelResolver::find(Company::class, $id, $principal); + + return $company !== null && $company->uuid === $principal->cid; + } + + /** + * `api.{id}`: an API credential of the principal's company, or the id of a personal access + * token owned by one of its users. + */ + public static function api(SocketPrincipal $principal, string $id): bool + { + if (!$principal->isCompanyScoped() || $principal->cid === null) { + return false; + } + + if (ctype_digit($id)) { + $token = PersonalAccessToken::on(ModelChannelResolver::connection($principal))->find((int) $id); + + return $token !== null && $token->tokenable instanceof User && static::isMember($principal, $token->tokenable->uuid); + } + + $credential = ApiCredential::on(ModelChannelResolver::connection($principal))->where('uuid', $id)->first() + ?? ApiCredential::on($principal->env === 'test' ? null : 'sandbox')->where('uuid', $id)->first(); + + return $credential !== null && $credential->company_uuid === $principal->cid; + } + + /** + * `user.{uuid|public_id}`: a member of the principal's company. Drivers and customers only + * reach their own user channel, which the local self rules already allow. + */ + public static function user(SocketPrincipal $principal, string $id): bool + { + if (!$principal->isCompanyScoped() || $principal->cid === null) { + return false; + } + + $user = ModelChannelResolver::find(User::class, $id, $principal); + + return $user !== null && static::isMember($principal, $user->uuid); + } + + /** + * `test.{user uuid}`: the admin socket test channel, for that user or a system admin. + */ + public static function test(SocketPrincipal $principal, string $id): bool + { + return $principal->adm || $principal->owns($id); + } + + /** + * `install.{company uuid}.*` and `uninstall.{company uuid}.*`: extension install progress. + */ + public static function install(SocketPrincipal $principal, string $id): bool + { + return $principal->isCompanyScoped() && $principal->cid !== null && str_starts_with($id, $principal->cid . '.'); + } + + public static function participatesInChannel(SocketPrincipal $principal, ChatChannel $chatChannel): bool + { + return static::isChatParticipant($principal, $chatChannel->uuid); + } + + public static function isParticipant(SocketPrincipal $principal, ChatParticipant $participant): bool + { + return $principal->owns((string) $participant->user_uuid); + } + + public static function participatesInMessage(SocketPrincipal $principal, ChatMessage $message): bool + { + return static::isChatParticipant($principal, $message->chat_channel_uuid); + } + + /** + * Whether one of the principal's own ids takes part in the chat channel. + */ + public static function isChatParticipant(SocketPrincipal $principal, ?string $chatChannelUuid): bool + { + if (!$chatChannelUuid || $principal->ids === []) { + return false; + } + + return ChatParticipant::on(ModelChannelResolver::connection($principal)) + ->where('chat_channel_uuid', $chatChannelUuid) + ->whereIn('user_uuid', $principal->ids) + ->exists(); + } + + /** + * Whether the user belongs to the principal's company. + */ + public static function isMember(SocketPrincipal $principal, ?string $userUuid): bool + { + if (!$userUuid || $principal->cid === null) { + return false; + } + + $connection = ModelChannelResolver::connection($principal); + + return CompanyUser::on($connection)->where('user_uuid', $userUuid)->where('company_uuid', $principal->cid)->exists() + || User::on($connection)->where('uuid', $userUuid)->where('company_uuid', $principal->cid)->exists(); + } +} diff --git a/src/Support/SocketCluster/ModelChannelResolver.php b/src/Support/SocketCluster/ModelChannelResolver.php new file mode 100644 index 00000000..583df828 --- /dev/null +++ b/src/Support/SocketCluster/ModelChannelResolver.php @@ -0,0 +1,64 @@ +narrow = $narrow; + } + + public function authorize(SocketPrincipal $principal, string $id, string $channel): bool + { + $narrowed = $principal->kind === 'driver' || $principal->kind === 'customer'; + + if (!$principal->isCompanyScoped() && !$narrowed) { + return false; + } + + $model = static::find($this->modelClass, $id, $principal); + + if ($model === null) { + return false; + } + + if ($principal->isCompanyScoped()) { + return $principal->cid !== null && $model->company_uuid === $principal->cid; + } + + return $this->narrow !== null && (bool) call_user_func($this->narrow, $principal, $model); + } + + /** + * Find a model by uuid or public_id in the principal's environment (sandbox for test). + */ + public static function find(string $modelClass, string $id, SocketPrincipal $principal): ?object + { + return $modelClass::on(static::connection($principal)) + ->where(function ($query) use ($id) { + $query->where('uuid', $id)->orWhere('public_id', $id); + }) + ->first(); + } + + /** + * The database connection for the principal's environment; null means the default one. + */ + public static function connection(SocketPrincipal $principal): ?string + { + return $principal->env === 'test' ? 'sandbox' : null; + } +} diff --git a/src/Support/SocketCluster/SocketChannelRegistry.php b/src/Support/SocketCluster/SocketChannelRegistry.php new file mode 100644 index 00000000..dad4f476 --- /dev/null +++ b/src/Support/SocketCluster/SocketChannelRegistry.php @@ -0,0 +1,79 @@ +registerModel('order', Order::class, $narrow); + */ +class SocketChannelRegistry +{ + /** + * @var array + */ + protected array $resolvers = []; + + /** + * @var array + */ + protected array $principalResolvers = []; + + /** + * Register the resolver for a prefix: fn (SocketPrincipal $p, string $id, string $channel): bool. + * + * A later registration for the same prefix replaces the earlier one. + */ + public function register(string $prefix, callable|SocketChannelResolver $resolver): void + { + $this->resolvers[$prefix] = $resolver; + } + + /** + * Register a prefix whose id is a model's uuid or public_id. + * + * User and API principals are allowed when the model belongs to their company. Driver and + * customer principals are allowed only when $narrow (fn (SocketPrincipal $p, $model): bool) + * says so; without it they are denied. Every other kind is denied. + */ + public function registerModel(string $prefix, string $modelClass, ?callable $narrow = null): void + { + $this->register($prefix, new ModelChannelResolver($modelClass, $narrow)); + } + + /** + * Register a resolver that may claim a Sanctum-authenticated user as a more specific principal: + * fn (Request $request, $user): ?SocketPrincipal. The first non-null answer wins. + */ + public function registerPrincipalResolver(callable $resolver): void + { + $this->principalResolvers[] = $resolver; + } + + public function resolve(string $prefix): callable|SocketChannelResolver|null + { + return $this->resolvers[$prefix] ?? null; + } + + /** + * The principal a registered resolver claims for the user, or null when none does. + */ + public function resolvePrincipal(Request $request, $user): ?SocketPrincipal + { + foreach ($this->principalResolvers as $resolver) { + $principal = $resolver($request, $user); + + if ($principal instanceof SocketPrincipal) { + return $principal; + } + } + + return null; + } +} diff --git a/src/Support/SocketCluster/SocketPrincipal.php b/src/Support/SocketCluster/SocketPrincipal.php new file mode 100644 index 00000000..649358fc --- /dev/null +++ b/src/Support/SocketCluster/SocketPrincipal.php @@ -0,0 +1,199 @@ +company_uuid; + + return new self( + kind: 'user', + sub: (string) $user->uuid, + cid: self::stringOrNull($cid), + cpid: self::companyPublicId($cid), + env: 'live', + ids: self::stringList([$user->uuid, $user->public_id]), + adm: $user->isAdmin() + ); + } + + /** + * An API credential; test-mode credentials act on the sandbox environment. + */ + public static function forApiCredential(ApiCredential $credential): self + { + return new self( + kind: 'api', + sub: (string) $credential->uuid, + cid: self::stringOrNull($credential->company_uuid), + cpid: self::companyPublicId($credential->company_uuid, $credential->getConnectionName()), + env: $credential->test_mode ? 'test' : 'live', + ids: self::stringList([$credential->uuid]) + ); + } + + /** + * The platform itself, which may subscribe to any channel. + */ + public static function system(): self + { + return new self(kind: 'system', sub: 'system'); + } + + /** + * The claims this principal contributes to a token, without the unset optional ones. + */ + public function toClaims(): array + { + return array_filter([ + 'kind' => $this->kind, + 'sub' => $this->sub, + 'cid' => $this->cid, + 'cpid' => $this->cpid, + 'env' => $this->env, + 'ids' => $this->ids, + 'adm' => $this->adm, + 'scp' => $this->scp, + 'sid' => $this->sid, + 'jti' => $this->jti, + 'exp' => $this->exp, + ], fn ($value) => $value !== null); + } + + /** + * A copy of this principal with the given properties replaced. + */ + public function with(array $changes): self + { + return new self(...array_merge([ + 'kind' => $this->kind, + 'sub' => $this->sub, + 'cid' => $this->cid, + 'cpid' => $this->cpid, + 'env' => $this->env, + 'ids' => $this->ids, + 'adm' => $this->adm, + 'scp' => $this->scp, + 'sid' => $this->sid, + 'jti' => $this->jti, + 'exp' => $this->exp, + ], $changes)); + } + + public function isSystem(): bool + { + return $this->kind === 'system'; + } + + public function isCompanyScoped(): bool + { + return $this->kind === 'user' || $this->kind === 'api'; + } + + public function owns(string $id): bool + { + return $id !== '' && in_array($id, $this->ids, true); + } + + /** + * Seconds until the token this principal came from expires, or null when it carries no expiry. + */ + public function secondsRemaining(): ?int + { + return $this->exp === null ? null : $this->exp - Carbon::now()->getTimestamp(); + } + + private static function companyPublicId(?string $companyUuid, ?string $connection = null): ?string + { + if (!$companyUuid) { + return null; + } + + return self::stringOrNull(Company::on($connection)->where('uuid', $companyUuid)->value('public_id')); + } + + private static function stringOrNull(mixed $value): ?string + { + return is_string($value) && $value !== '' ? $value : null; + } + + private static function stringList(mixed $values): array + { + return array_values(array_filter((array) $values, fn ($value) => is_string($value) && $value !== '')); + } + + private static function timestamp(mixed $value): ?int + { + if ($value instanceof \DateTimeInterface) { + return $value->getTimestamp(); + } + + return is_numeric($value) ? (int) $value : null; + } +} diff --git a/src/Support/SocketCluster/SocketSignature.php b/src/Support/SocketCluster/SocketSignature.php new file mode 100644 index 00000000..ffdae52d --- /dev/null +++ b/src/Support/SocketCluster/SocketSignature.php @@ -0,0 +1,77 @@ +getTimestamp(); + + return [ + self::HEADER_TIMESTAMP => $timestamp, + self::HEADER_SIGNATURE => static::sign($purpose, $timestamp, $body), + ]; + } + + /** + * Whether a request's timestamp and signature are valid for the raw body, compared in constant time. + */ + public static function verify(string $purpose, ?string $timestamp, ?string $signature, string $body): bool + { + if (!SocketToken::enabled() || !is_string($timestamp) || !ctype_digit($timestamp) || !is_string($signature) || $signature === '') { + return false; + } + + if (abs(Carbon::now()->getTimestamp() - (int) $timestamp) > self::TOLERANCE) { + return false; + } + + return hash_equals(static::sign($purpose, $timestamp, $body), strtolower($signature)); + } +} diff --git a/src/Support/SocketCluster/SocketToken.php b/src/Support/SocketCluster/SocketToken.php new file mode 100644 index 00000000..57ae85b6 --- /dev/null +++ b/src/Support/SocketCluster/SocketToken.php @@ -0,0 +1,214 @@ += self::MIN_KEY_LENGTH ? $key : null; + } + + public static function enabled(): bool + { + return static::key() !== null; + } + + /** + * Mint a token for the principal and return the token endpoint response body. + * + * @return array{token: string, expires_in: int, expires_at: string} + */ + public static function issue(SocketPrincipal $principal, ?int $ttl = null): array + { + $configuration = static::configuration(); + $ttl = max(1, min(self::MAX_TTL, $ttl ?? static::defaultTtl($principal->kind))); + $now = Carbon::now()->getTimestamp(); + $issuedAt = new \DateTimeImmutable('@' . $now); + $expiresAt = new \DateTimeImmutable('@' . ($now + $ttl)); + + $builder = $configuration->builder() + ->issuedBy(self::ISSUER) + ->permittedFor(self::AUDIENCE) + ->identifiedBy((string) Str::uuid()) + ->relatedTo($principal->sub) + ->issuedAt($issuedAt) + ->canOnlyBeUsedAfter($issuedAt) + ->expiresAt($expiresAt); + + $claims = $principal->toClaims(); + unset($claims['sub'], $claims['jti'], $claims['exp']); + + foreach ($claims as $name => $value) { + $builder = $builder->withClaim($name, $value); + } + + return [ + 'token' => $builder->getToken($configuration->signer(), $configuration->signingKey())->toString(), + 'expires_in' => $ttl, + 'expires_at' => $expiresAt->format(DATE_ATOM), + ]; + } + + /** + * The principal a token was minted for, or null when it is not one of ours or no longer valid. + * + * Rejects anything not signed HS256 with the configured key (including alg "none" and + * asymmetric algorithms), a wrong issuer or audience, and missing or out-of-range iat/nbf/exp. + */ + public static function verify(string $jwt): ?SocketPrincipal + { + if ($jwt === '' || !static::enabled()) { + return null; + } + + try { + $configuration = static::configuration(); + $token = $configuration->parser()->parse($jwt); + + if (!$token instanceof Plain || $token->headers()->get('alg') !== self::ALGORITHM) { + return null; + } + + $configuration->validator()->assert( + $token, + new SignedWith($configuration->signer(), $configuration->verificationKey()), + new IssuedBy(self::ISSUER), + new PermittedFor(self::AUDIENCE), + new StrictValidAt(new FrozenClock(Carbon::now()->toDateTimeImmutable())) + ); + + return SocketPrincipal::fromClaims($token->claims()->all()); + } catch (\Throwable $e) { + return null; + } + } + + /** + * A short-lived token for the platform itself, which may subscribe to any channel. + */ + public static function system(): string + { + return static::issue(SocketPrincipal::system())['token']; + } + + /** + * Mint the scoped token for one customer's public tracking channel. + * + * Accepts FleetOps' TrackingScope (order_uuid, customer_type, customer_uuid). The token + * may subscribe to `tracking.{opaque}` and nothing else. + * + * @return array{token: string, expires_in: int, expires_at: string} + */ + public static function forTracking(object $scope): array + { + $orderUuid = (string) data_get($scope, 'order_uuid'); + $trackingId = static::trackingId($orderUuid, (string) data_get($scope, 'customer_type'), (string) data_get($scope, 'customer_uuid')); + $companyId = data_get($scope, 'company_uuid') ?: DB::table('orders')->where('uuid', $orderUuid)->value('company_uuid'); + + return static::issue(new SocketPrincipal( + kind: 'tracking', + sub: $trackingId, + cid: is_string($companyId) && $companyId !== '' ? $companyId : null, + scp: ['tracking.' . $trackingId] + ), self::TRACKING_TTL); + } + + /** + * The opaque id of a customer's public tracking channel: lowercase unpadded base32 of + * HMAC-SHA256(tracking key, "{order_uuid}:{customer_type}:{customer_uuid}"), first 26 characters. + */ + public static function trackingId(string $orderUuid, string $customerType, string $customerUuid): string + { + $digest = hash_hmac('sha256', $orderUuid . ':' . $customerType . ':' . $customerUuid, SocketSignature::deriveKey(SocketSignature::TRACKING), true); + + return substr(static::base32($digest), 0, self::TRACKING_ID_LENGTH); + } + + /** + * RFC 4648 base32, lowercase and without padding. + */ + public static function base32(string $bytes): string + { + $alphabet = 'abcdefghijklmnopqrstuvwxyz234567'; + $bits = ''; + $encoded = ''; + + foreach (str_split($bytes) as $byte) { + $bits .= str_pad(decbin(ord($byte)), 8, '0', STR_PAD_LEFT); + } + + foreach (str_split($bits, 5) as $chunk) { + $encoded .= $alphabet[bindec(str_pad($chunk, 5, '0', STR_PAD_RIGHT))]; + } + + return $encoded; + } + + public static function defaultTtl(string $kind): int + { + $configured = (int) config('broadcasting.connections.socketcluster.token_ttl', self::DEFAULT_TTL); + + return match ($kind) { + 'tracking' => self::TRACKING_TTL, + 'system' => self::SYSTEM_TTL, + default => $configured > 0 ? $configured : self::DEFAULT_TTL, + }; + } + + protected static function configuration(): Configuration + { + $key = static::key(); + + if ($key === null) { + throw new \RuntimeException('Socket authentication is not configured.'); + } + + return Configuration::forSymmetricSigner(new Sha256(), InMemory::plainText($key)); + } +} diff --git a/tests/Fixtures/Support/SocketAuthFixtures.php b/tests/Fixtures/Support/SocketAuthFixtures.php new file mode 100644 index 00000000..fb09a309 --- /dev/null +++ b/tests/Fixtures/Support/SocketAuthFixtures.php @@ -0,0 +1,324 @@ + $key, + 'broadcasting.connections.socketcluster.publish_url' => 'http://socket.test:8001', + 'broadcasting.connections.socketcluster.token_ttl' => 900, + 'broadcasting.connections.socketcluster.options' => [ + 'secure' => false, + 'host' => 'socket.test', + 'port' => 8000, + 'path' => '/socketcluster/', + 'query' => [], + ], + ], $config)); + $container->instance(HttpFactory::class, new HttpFactory()); + Facade::clearResolvedInstances(); + Carbon::setTestNow(Carbon::createFromTimestampUTC(self::NOW)); + session()->flush(); + + return $container; + } + + /** + * Undo container(): a fresh container (so socket authentication is off again), and the + * clock, session and booted models released. + */ + public static function reset(): void + { + Carbon::setTestNow(); + session()->flush(); + EloquentModel::clearBootedModels(); + Container::setInstance(new \FleetbaseTestContainer()); + Facade::clearResolvedInstances(); + } + + /** + * Seeded databases for resolver, principal and controller tests. + * + * @param array $skipTables tables to leave out, to exercise missing-schema paths + */ + public static function database(?string $key = self::KEY, array $skipTables = [], bool $seed = true): Capsule + { + EloquentModel::clearBootedModels(); + + $connection = [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]; + + $container = static::container($key, [ + 'database.default' => 'mysql', + 'database.connections.mysql' => $connection, + 'database.connections.sandbox' => $connection, + 'fleetbase.connection.db' => 'mysql', + ]); + + $capsule = new Capsule($container); + $capsule->addConnection($connection, 'mysql'); + $capsule->addConnection($connection, 'sandbox'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + $capsule->getDatabaseManager()->setDefaultConnection('mysql'); + + $container->instance('db', $capsule->getDatabaseManager()); + Facade::clearResolvedInstance('db'); + + foreach (['mysql', 'sandbox'] as $name) { + static::createSchema($capsule, $name, $skipTables); + } + + if ($seed) { + static::seed($capsule); + } + + return $capsule; + } + + /** + * An unsigned or HS256-signed JWT with exactly the given header and claims. + */ + public static function jwt(array $header, array $claims, ?string $key = null): string + { + $unsigned = static::base64Url(json_encode($header)) . '.' . static::base64Url(json_encode($claims)); + $signature = $key === null ? '' : static::base64Url(hash_hmac('sha256', $unsigned, $key, true)); + + return $unsigned . '.' . $signature; + } + + /** + * Valid claims for a hand-built token, before any test-specific changes. + */ + public static function claims(array $overrides = []): array + { + return array_merge([ + 'iss' => 'fleetbase-api', + 'aud' => 'fleetbase-socket', + 'iat' => self::NOW, + 'nbf' => self::NOW, + 'exp' => self::NOW + 600, + 'jti' => 'jti-handmade', + 'sub' => 'user-a1', + 'kind' => 'user', + 'cid' => 'company-a', + 'env' => 'live', + 'ids' => ['user-a1'], + 'adm' => false, + ], $overrides); + } + + /** + * The decoded payload segment of a JWT. + */ + public static function payload(string $jwt): array + { + return json_decode(static::base64UrlDecode(explode('.', $jwt)[1]), true); + } + + /** + * The decoded header segment of a JWT. + */ + public static function header(string $jwt): array + { + return json_decode(static::base64UrlDecode(explode('.', $jwt)[0]), true); + } + + public static function user(array $attributes = []): User + { + $user = new User(); + $user->setRawAttributes(array_merge([ + 'uuid' => 'user-a1', + 'public_id' => 'user_a1', + 'company_uuid' => 'company-a', + 'type' => 'user', + ], $attributes)); + + return $user; + } + + protected static function base64Url(string $value): string + { + return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); + } + + protected static function base64UrlDecode(string $value): string + { + return base64_decode(strtr($value, '-_', '+/')); + } + + protected static function createSchema(Capsule $capsule, string $name, array $skipTables): void + { + $schema = $capsule->getConnection($name)->getSchemaBuilder(); + $tables = [ + 'companies' => function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable(); + $table->string('name')->nullable(); + }, + 'users' => function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('type')->nullable(); + }, + 'company_users' => function ($table) { + $table->string('uuid')->primary(); + $table->string('company_uuid')->nullable(); + $table->string('user_uuid')->nullable(); + }, + 'api_credentials' => function ($table) { + $table->string('uuid')->primary(); + $table->string('company_uuid')->nullable(); + $table->string('user_uuid')->nullable(); + $table->string('key')->nullable(); + $table->boolean('test_mode')->default(false); + $table->timestamp('expires_at')->nullable(); + }, + 'personal_access_tokens' => function ($table) { + $table->increments('id'); + $table->string('tokenable_type'); + $table->string('tokenable_id'); + $table->string('name')->nullable(); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + }, + 'chat_channels' => function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + }, + 'chat_participants' => function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('chat_channel_uuid')->nullable(); + $table->string('user_uuid')->nullable(); + }, + 'chat_messages' => function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('chat_channel_uuid')->nullable(); + }, + 'files' => function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + }, + 'orders' => function ($table) { + $table->string('uuid')->primary(); + $table->string('company_uuid')->nullable(); + }, + ]; + + foreach ($tables as $table => $definition) { + if (in_array($table, $skipTables, true)) { + continue; + } + + $schema->create($table, function ($blueprint) use ($definition) { + $definition($blueprint); + $blueprint->timestamp('created_at')->nullable(); + $blueprint->timestamp('updated_at')->nullable(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + } + } + + protected static function seed(Capsule $capsule): void + { + $live = $capsule->getConnection('mysql'); + $sandbox = $capsule->getConnection('sandbox'); + + $live->table('companies')->insert([ + ['uuid' => 'company-a', 'public_id' => 'company_aaa', 'name' => 'Company A'], + ['uuid' => 'company-b', 'public_id' => 'company_bbb', 'name' => 'Company B'], + ]); + $live->table('users')->insert([ + ['uuid' => 'user-a1', 'public_id' => 'user_a1', 'company_uuid' => 'company-a', 'type' => 'user'], + ['uuid' => 'user-a2', 'public_id' => 'user_a2', 'company_uuid' => 'company-a', 'type' => 'user'], + ['uuid' => 'user-b1', 'public_id' => 'user_b1', 'company_uuid' => 'company-b', 'type' => 'user'], + ]); + // user-a2 has no company_users row: membership then falls back to users.company_uuid. + $live->table('company_users')->insert([ + ['uuid' => 'company-user-a1', 'company_uuid' => 'company-a', 'user_uuid' => 'user-a1'], + ['uuid' => 'company-user-b1', 'company_uuid' => 'company-b', 'user_uuid' => 'user-b1'], + ]); + $live->table('api_credentials')->insert([ + ['uuid' => 'cred-a', 'company_uuid' => 'company-a', 'user_uuid' => 'user-a1', 'key' => 'flb_live_a', 'test_mode' => false], + ['uuid' => 'cred-b', 'company_uuid' => 'company-b', 'user_uuid' => 'user-b1', 'key' => 'flb_live_b', 'test_mode' => false], + ]); + $live->table('personal_access_tokens')->insert([ + ['id' => 1, 'tokenable_type' => User::class, 'tokenable_id' => 'user-a1', 'name' => 'navigator', 'token' => hash('sha256', 'plain-token-a1'), 'abilities' => '["*"]'], + ['id' => 2, 'tokenable_type' => User::class, 'tokenable_id' => 'user-b1', 'name' => 'navigator', 'token' => hash('sha256', 'plain-token-b1'), 'abilities' => '["*"]'], + ]); + $live->table('chat_channels')->insert([ + ['uuid' => 'chat-a', 'public_id' => 'chat_aaa', 'company_uuid' => 'company-a'], + ['uuid' => 'chat-b', 'public_id' => 'chat_bbb', 'company_uuid' => 'company-b'], + ]); + $live->table('chat_participants')->insert([ + ['uuid' => 'participant-a1', 'public_id' => 'chat_participant_a1', 'company_uuid' => 'company-a', 'chat_channel_uuid' => 'chat-a', 'user_uuid' => 'user-a1'], + ['uuid' => 'participant-b1', 'public_id' => 'chat_participant_b1', 'company_uuid' => 'company-b', 'chat_channel_uuid' => 'chat-b', 'user_uuid' => 'user-b1'], + ]); + $live->table('chat_messages')->insert([ + ['uuid' => 'message-a', 'public_id' => 'chat_message_a', 'company_uuid' => 'company-a', 'chat_channel_uuid' => 'chat-a'], + ['uuid' => 'message-b', 'public_id' => 'chat_message_b', 'company_uuid' => 'company-b', 'chat_channel_uuid' => 'chat-b'], + ]); + $live->table('files')->insert([ + ['uuid' => 'file-a', 'public_id' => 'file_aaa', 'company_uuid' => 'company-a'], + ['uuid' => 'file-b', 'public_id' => 'file_bbb', 'company_uuid' => 'company-b'], + ]); + $live->table('orders')->insert([ + ['uuid' => 'order-a', 'company_uuid' => 'company-a'], + ]); + + // The sandbox carries synced companies and its own test-mode records. + $sandbox->table('companies')->insert([ + ['uuid' => 'company-a', 'public_id' => 'company_aaa', 'name' => 'Company A'], + ]); + $sandbox->table('api_credentials')->insert([ + ['uuid' => 'cred-a-test', 'company_uuid' => 'company-a', 'user_uuid' => 'user-a1', 'key' => 'flb_test_a', 'test_mode' => true], + ]); + $sandbox->table('files')->insert([ + ['uuid' => 'file-a-test', 'public_id' => 'file_aaa_test', 'company_uuid' => 'company-a'], + ]); + } +} diff --git a/tests/Unit/Providers/CoreProviderContractsTest.php b/tests/Unit/Providers/CoreProviderContractsTest.php index 7bf593a0..68d485a3 100644 --- a/tests/Unit/Providers/CoreProviderContractsTest.php +++ b/tests/Unit/Providers/CoreProviderContractsTest.php @@ -148,6 +148,9 @@ interface ShouldQueue use Fleetbase\Services\TemplateRenderService; use Fleetbase\Support\NotificationRegistry; use Fleetbase\Support\Reporting\ReportSchemaRegistry; + use Fleetbase\Support\SocketCluster\ChannelAuthorizer; + use Fleetbase\Support\SocketCluster\ModelChannelResolver; + use Fleetbase\Support\SocketCluster\SocketChannelRegistry; use Fleetbase\Support\SocketCluster\SocketClusterBroadcaster; use Fleetbase\Webhook\Events\FinalWebhookCallFailedEvent; use Fleetbase\Webhook\Events\WebhookCallFailedEvent; @@ -1012,6 +1015,23 @@ class_alias(CoreProviderContractsFailingMixinMacro::class, 'Fleetbase\\ProviderF Facade::clearResolvedInstance('Broadcast'); }); + test('socket cluster provider shares one channel registry with core resolvers and one authorizer', function () { + $container = bind_test_container(); + + (new SocketClusterServiceProvider($container))->register(); + + $registry = $container->make(SocketChannelRegistry::class); + + expect($container->make(SocketChannelRegistry::class))->toBe($registry) + ->and($registry->resolve('chat'))->toBeInstanceOf(ModelChannelResolver::class) + ->and($registry->resolve('company'))->not->toBeNull() + ->and($container->make(ChannelAuthorizer::class))->toBeInstanceOf(ChannelAuthorizer::class) + ->and($container->make(ChannelAuthorizer::class))->toBe($container->make(ChannelAuthorizer::class)); + + $container->offsetUnset(SocketChannelRegistry::class); + $container->offsetUnset(ChannelAuthorizer::class); + }); + test('webhook server provider configures package name and config file', function () { $package = new Package(); diff --git a/tests/Unit/Support/SocketChannelAuthorizationTest.php b/tests/Unit/Support/SocketChannelAuthorizationTest.php new file mode 100644 index 00000000..b539ddc8 --- /dev/null +++ b/tests/Unit/Support/SocketChannelAuthorizationTest.php @@ -0,0 +1,372 @@ +calls[] = [$principal->sub, $id, $channel]; + + return $this->answer; + } +} + +/** + * A company A console user by default; overrides replace any constructor argument. + */ +function socket_channel_principal(array $overrides = []): SocketPrincipal +{ + return new SocketPrincipal(...array_merge([ + 'kind' => 'user', + 'sub' => 'user-a1', + 'cid' => 'company-a', + 'cpid' => 'company_aaa', + 'ids' => ['user-a1', 'user_a1'], + 'jti' => null, + 'exp' => SocketAuthFixtures::NOW + 900, + ], $overrides)); +} + +function socket_channel_driver(array $overrides = []): SocketPrincipal +{ + return socket_channel_principal(array_merge([ + 'kind' => 'driver', + 'sub' => 'driver-1', + 'cpid' => null, + 'ids' => ['driver-1', 'driver_1', 'user-a1'], + ], $overrides)); +} + +function socket_channel_core_authorizer(): ChannelAuthorizer +{ + $registry = new SocketChannelRegistry(); + CoreChannelResolvers::register($registry); + + return new ChannelAuthorizer($registry); +} + +afterEach(function () { + SocketAuthFixtures::reset(); +}); + +test('the authorizer denies malformed channel names before anything else', function (string $channel) { + SocketAuthFixtures::container(); + + $decision = (new ChannelAuthorizer(new SocketChannelRegistry()))->authorize(SocketPrincipal::system(), $channel); + + expect($decision->toArray())->toBe(['allow' => false, 'ttl' => 30, 'reason' => 'invalid_channel']); +})->with([ + 'empty' => [''], + 'whitespace' => ['order.order 1'], + 'too long' => [str_repeat('a', 256)], +]); + +test('anonymous connections may follow the install channel only until setup creates a user', function () { + SocketAuthFixtures::database(SocketAuthFixtures::KEY, ['users']); + $missingSchema = (new ChannelAuthorizer(new SocketChannelRegistry()))->authorize(null, 'fleetbase.install'); + + SocketAuthFixtures::database(SocketAuthFixtures::KEY, [], false); + $noUsers = (new ChannelAuthorizer(new SocketChannelRegistry()))->authorize(null, 'fleetbase.install'); + + SocketAuthFixtures::database(); + $authorizer = new ChannelAuthorizer(new SocketChannelRegistry()); + + expect($missingSchema->toArray())->toBe(['allow' => true, 'ttl' => 30, 'reason' => 'install_pending']) + ->and($noUsers->toArray())->toBe(['allow' => true, 'ttl' => 30, 'reason' => 'install_pending']) + ->and($authorizer->authorize(null, 'fleetbase.install')->toArray())->toBe(['allow' => false, 'ttl' => 30, 'reason' => 'no_token']) + ->and($authorizer->authorize(null, 'company.company_aaa')->reason)->toBe('no_token'); +}); + +test('expired principals are denied', function () { + SocketAuthFixtures::container(); + + $authorizer = new ChannelAuthorizer(new SocketChannelRegistry()); + + expect($authorizer->authorize(socket_channel_principal(['exp' => SocketAuthFixtures::NOW - 1]), 'company.company-a')->reason)->toBe('expired') + ->and($authorizer->authorize(socket_channel_principal(['exp' => SocketAuthFixtures::NOW]), 'company.company-a')->reason)->toBe('expired') + ->and($authorizer->authorize(SocketPrincipal::system(), 'company.company-a')->toArray())->toBe(['allow' => true, 'ttl' => 300, 'reason' => 'system']); +}); + +test('scoped tokens may follow exactly their listed channels and nothing else', function () { + SocketAuthFixtures::container(); + + $registry = new SocketChannelRegistry(); + $resolver = new SocketChannelAuthorizationRecordingResolver(true); + $registry->register('checkout', $resolver); + $registry->register('company', $resolver); + + $authorizer = new ChannelAuthorizer($registry); + $checkout = new SocketPrincipal(kind: 'checkout', sub: 'checkout-1', cid: 'company-a', scp: ['checkout.checkout_1'], exp: SocketAuthFixtures::NOW + 900); + $system = SocketPrincipal::system()->with(['scp' => ['tracking.abc']]); + + expect($authorizer->authorize($checkout, 'checkout.checkout_1')->toArray())->toBe(['allow' => true, 'ttl' => 300, 'reason' => 'scope']) + ->and($authorizer->authorize($checkout, 'checkout.checkout_2')->reason)->toBe('out_of_scope') + ->and($authorizer->authorize($checkout, 'company.company-a')->reason)->toBe('out_of_scope') + ->and($authorizer->authorize($system, 'order.order_1')->reason)->toBe('out_of_scope') + ->and($resolver->calls)->toBe([]); +}); + +test('principals follow their own channels without a lookup', function () { + SocketAuthFixtures::container(); + + $user = socket_channel_principal(); + $api = socket_channel_principal(['kind' => 'api', 'sub' => 'cred-a', 'ids' => ['cred-a']]); + $driver = socket_channel_driver(); + $noCpid = socket_channel_principal(['cpid' => null]); + + foreach (['company.company-a', 'company.company_aaa', 'user.user-a1', 'user.user_a1', 'driver.user_a1', 'install.company-a.fleetops', 'uninstall.company-a.fleetops'] as $channel) { + expect(ChannelAuthorizer::isSelfChannel($user, $channel))->toBeTrue(); + } + + expect(ChannelAuthorizer::isSelfChannel($api, 'api.cred-a'))->toBeTrue() + ->and(ChannelAuthorizer::isSelfChannel($api, 'company.company-a'))->toBeTrue() + ->and(ChannelAuthorizer::isSelfChannel($api, 'install.company-a.fleetops'))->toBeFalse() + ->and(ChannelAuthorizer::isSelfChannel($user, 'api.user-a1'))->toBeFalse() + ->and(ChannelAuthorizer::isSelfChannel($user, 'install.company-b.fleetops'))->toBeFalse() + ->and(ChannelAuthorizer::isSelfChannel($user, 'user.user-b1'))->toBeFalse() + ->and(ChannelAuthorizer::isSelfChannel($noCpid, 'company.'))->toBeFalse() + ->and(ChannelAuthorizer::isSelfChannel($driver, 'driver.driver_1'))->toBeTrue() + ->and(ChannelAuthorizer::isSelfChannel($driver, 'user.user-a1'))->toBeTrue() + ->and(ChannelAuthorizer::isSelfChannel($driver, 'company.company-a'))->toBeFalse() + ->and(ChannelAuthorizer::isSelfChannel(socket_channel_principal(['cid' => null]), 'install..fleetops'))->toBeFalse() + ->and((new ChannelAuthorizer(new SocketChannelRegistry()))->authorize($driver, 'driver.driver-1')->reason)->toBe('self'); +}); + +test('other channels are decided by the resolver registered for their prefix', function () { + SocketAuthFixtures::container(); + + $registry = new SocketChannelRegistry(); + $orders = new SocketChannelAuthorizationRecordingResolver(true); + $registry->register('order', $orders); + $registry->register('vehicle', function (SocketPrincipal $principal, string $id, string $channel) { + return $id === 'vehicle_1'; + }); + $registry->register('broken', function () { + throw new RuntimeException('lookup failed'); + }); + + $authorizer = new ChannelAuthorizer($registry); + $user = socket_channel_principal(); + + expect($authorizer->authorize($user, 'order.order_1.extra')->toArray())->toBe(['allow' => true, 'ttl' => 300, 'reason' => 'resolver']) + ->and($orders->calls)->toBe([['user-a1', 'order_1.extra', 'order.order_1.extra']]) + ->and($authorizer->authorize($user, 'vehicle.vehicle_1')->reason)->toBe('resolver') + ->and($authorizer->authorize($user, 'vehicle.vehicle_2')->toArray())->toBe(['allow' => false, 'ttl' => 30, 'reason' => 'forbidden']) + ->and($authorizer->authorize($user, 'broken.anything')->reason)->toBe('resolver_error') + ->and(app('log')->entries)->toBe([ + ['warning', 'Socket channel resolver failed.', ['prefix' => 'broken', 'error' => 'lookup failed']], + ]) + ->and($authorizer->authorize($user, 'unknown.anything')->reason)->toBe('unknown_prefix') + ->and($authorizer->authorize($user, 'order')->reason)->toBe('unknown_prefix') + ->and($authorizer->authorize($user, 'order.')->reason)->toBe('unknown_prefix'); +}); + +test('decisions are cached per token and channel for their capped lifetime', function () { + SocketAuthFixtures::container(); + + $registry = new SocketChannelRegistry(); + $orders = new SocketChannelAuthorizationRecordingResolver(true); + $vehicles = new SocketChannelAuthorizationRecordingResolver(false); + $registry->register('order', $orders); + $registry->register('vehicle', $vehicles); + + $authorizer = new ChannelAuthorizer($registry); + $principal = socket_channel_principal(['jti' => 'jti-cached', 'exp' => SocketAuthFixtures::NOW + 100]); + $first = $authorizer->authorize($principal, 'order.order_1'); + $second = $authorizer->authorize($principal, 'order.order_1'); + + $authorizer->authorize($principal, 'vehicle.vehicle_1'); + $denied = $authorizer->authorize($principal, 'vehicle.vehicle_1'); + + $uncached = socket_channel_principal(); + $authorizer->authorize($uncached, 'order.order_2'); + $authorizer->authorize($uncached, 'order.order_2'); + + expect($first->toArray())->toBe(['allow' => true, 'ttl' => 100, 'reason' => 'resolver']) + ->and($second->toArray())->toBe($first->toArray()) + ->and(app('cache')->get('socket-auth:' . sha1('jti-cached|order.order_1')))->toBe($first->toArray()) + ->and($denied->toArray())->toBe(['allow' => false, 'ttl' => 30, 'reason' => 'forbidden']) + ->and($vehicles->calls)->toHaveCount(1) + ->and($orders->calls)->toBe([ + ['user-a1', 'order_1', 'order.order_1'], + ['user-a1', 'order_2', 'order.order_2'], + ['user-a1', 'order_2', 'order.order_2'], + ]); +}); + +test('the registry keeps one resolver per prefix and the first principal a resolver claims', function () { + $registry = new SocketChannelRegistry(); + $request = Request::create('/v1/socket/token', 'POST'); + $first = new SocketChannelAuthorizationRecordingResolver(true); + $second = new SocketChannelAuthorizationRecordingResolver(false); + + $registry->register('order', $first); + $registry->register('order', $second); + $registry->registerModel('file', File::class); + + expect($registry->resolve('order'))->toBe($second) + ->and($registry->resolve('file'))->toBeInstanceOf(ModelChannelResolver::class) + ->and($registry->resolve('missing'))->toBeNull() + ->and($registry->resolvePrincipal($request, 'user-a1'))->toBeNull(); + + $registry->registerPrincipalResolver(function (Request $request, $user) { + return null; + }); + $registry->registerPrincipalResolver(function (Request $request, $user) { + return 'not a principal'; + }); + $registry->registerPrincipalResolver(function (Request $request, $user) { + return new SocketPrincipal(kind: 'driver', sub: 'driver-1', cid: 'company-a', ids: ['driver-1', $user]); + }); + $registry->registerPrincipalResolver(function () { + throw new RuntimeException('a later resolver is never asked'); + }); + + expect($registry->resolvePrincipal($request, 'user-a1')->ids)->toBe(['driver-1', 'user-a1']); +}); + +test('model channels belong to their company and are narrowed for drivers and customers', function () { + SocketAuthFixtures::database(); + + $resolver = new ModelChannelResolver(File::class); + $narrowed = new ModelChannelResolver(File::class, function (SocketPrincipal $principal, File $file) { + return $file->uuid === 'file-a'; + }); + $user = socket_channel_principal(); + $api = socket_channel_principal(['kind' => 'api', 'sub' => 'cred-a', 'ids' => ['cred-a']]); + $driver = socket_channel_driver(); + $checkout = new SocketPrincipal(kind: 'checkout', sub: 'checkout-1', cid: 'company-a'); + $sandbox = socket_channel_principal(['env' => 'test']); + + expect($resolver->authorize($user, 'file_aaa', 'file.file_aaa'))->toBeTrue() + ->and($resolver->authorize($user, 'file-a', 'file.file-a'))->toBeTrue() + ->and($resolver->authorize($api, 'file-a', 'file.file-a'))->toBeTrue() + ->and($resolver->authorize($user, 'file_bbb', 'file.file_bbb'))->toBeFalse() + ->and($resolver->authorize($user, 'file-missing', 'file.file-missing'))->toBeFalse() + ->and($resolver->authorize(socket_channel_principal(['cid' => null]), 'file-a', 'file.file-a'))->toBeFalse() + ->and($resolver->authorize($driver, 'file-a', 'file.file-a'))->toBeFalse() + ->and($narrowed->authorize($driver, 'file-a', 'file.file-a'))->toBeTrue() + ->and($narrowed->authorize($driver, 'file-b', 'file.file-b'))->toBeFalse() + ->and($narrowed->authorize($checkout, 'file-a', 'file.file-a'))->toBeFalse() + ->and($resolver->authorize($sandbox, 'file_aaa_test', 'file.file_aaa_test'))->toBeTrue() + ->and($resolver->authorize($sandbox, 'file-a', 'file.file-a'))->toBeFalse() + ->and(ModelChannelResolver::connection($sandbox))->toBe('sandbox') + ->and(ModelChannelResolver::connection($user))->toBeNull(); +}); + +test('core registers its channel prefixes', function () { + $registry = new SocketChannelRegistry(); + CoreChannelResolvers::register($registry); + + foreach (['company', 'api', 'user', 'test', 'install', 'uninstall'] as $prefix) { + expect($registry->resolve($prefix))->toBeArray(); + } + + foreach (['chat', 'chat_channel', 'chat_participant', 'chat_message', 'file'] as $prefix) { + expect($registry->resolve($prefix))->toBeInstanceOf(ModelChannelResolver::class); + } +}); + +test('company and user channels resolve only within the principal company', function () { + SocketAuthFixtures::database(); + + $user = socket_channel_principal(); + $driver = socket_channel_driver(); + + expect(CoreChannelResolvers::company($user, 'company-a'))->toBeTrue() + ->and(CoreChannelResolvers::company($user, 'company_aaa'))->toBeTrue() + ->and(CoreChannelResolvers::company($user, 'company_bbb'))->toBeFalse() + ->and(CoreChannelResolvers::company($user, 'company-missing'))->toBeFalse() + ->and(CoreChannelResolvers::company($driver, 'company-a'))->toBeFalse() + ->and(CoreChannelResolvers::user($user, 'user_a1'))->toBeTrue() + ->and(CoreChannelResolvers::user($user, 'user-a2'))->toBeTrue() + ->and(CoreChannelResolvers::user($user, 'user_b1'))->toBeFalse() + ->and(CoreChannelResolvers::user($user, 'user-missing'))->toBeFalse() + ->and(CoreChannelResolvers::user($driver, 'user-a2'))->toBeFalse() + ->and(CoreChannelResolvers::isMember($user, null))->toBeFalse() + ->and(CoreChannelResolvers::isMember(socket_channel_principal(['cid' => null]), 'user-a1'))->toBeFalse(); +}); + +test('api channels resolve to credentials and personal access tokens of the principal company', function () { + SocketAuthFixtures::database(); + + $user = socket_channel_principal(); + $sandbox = socket_channel_principal(['env' => 'test']); + + expect(CoreChannelResolvers::api($user, 'cred-a'))->toBeTrue() + ->and(CoreChannelResolvers::api($user, 'cred-b'))->toBeFalse() + ->and(CoreChannelResolvers::api($user, 'cred-missing'))->toBeFalse() + ->and(CoreChannelResolvers::api($user, 'cred-a-test'))->toBeTrue() + ->and(CoreChannelResolvers::api($sandbox, 'cred-a-test'))->toBeTrue() + ->and(CoreChannelResolvers::api($sandbox, 'cred-a'))->toBeTrue() + ->and(CoreChannelResolvers::api($user, '1'))->toBeTrue() + ->and(CoreChannelResolvers::api($user, '2'))->toBeFalse() + ->and(CoreChannelResolvers::api($user, '99'))->toBeFalse() + ->and(CoreChannelResolvers::api(socket_channel_driver(), 'cred-a'))->toBeFalse(); +}); + +test('test and install channels follow their owner and company', function () { + SocketAuthFixtures::container(); + + $user = socket_channel_principal(); + + expect(CoreChannelResolvers::test($user, 'user-a1'))->toBeTrue() + ->and(CoreChannelResolvers::test($user, 'user-b1'))->toBeFalse() + ->and(CoreChannelResolvers::test(socket_channel_principal(['adm' => true]), 'user-b1'))->toBeTrue() + ->and(CoreChannelResolvers::install($user, 'company-a.fleetops'))->toBeTrue() + ->and(CoreChannelResolvers::install($user, 'company-b.fleetops'))->toBeFalse() + ->and(CoreChannelResolvers::install(socket_channel_principal(['kind' => 'api', 'sub' => 'cred-a']), 'company-a.fleetops'))->toBeTrue() + ->and(CoreChannelResolvers::install(socket_channel_driver(), 'company-a.fleetops'))->toBeFalse() + ->and(CoreChannelResolvers::install(socket_channel_principal(['cid' => null]), 'company-a.fleetops'))->toBeFalse(); +}); + +test('company principals are denied every core channel of another company', function () { + SocketAuthFixtures::database(); + + $authorizer = socket_channel_core_authorizer(); + $user = socket_channel_principal(); + $api = socket_channel_principal(['kind' => 'api', 'sub' => 'cred-a', 'ids' => ['cred-a']]); + + foreach (['chat.chat_aaa', 'chat_channel.chat-a', 'chat_participant.participant-a1', 'chat_message.chat_message_a', 'file.file_aaa', 'user.user-a2', 'api.cred-a', 'test.user-a1'] as $channel) { + expect($authorizer->authorize($user, $channel)->allow)->toBeTrue(); + } + + foreach (['chat.chat_bbb', 'chat_channel.chat-b', 'chat_participant.participant-b1', 'chat_message.chat_message_b', 'file.file_bbb', 'user.user_b1', 'company.company_bbb', 'api.cred-b', 'api.2', 'test.user-b1', 'install.company-b.fleetops'] as $channel) { + expect($authorizer->authorize($user, $channel)->toArray())->toBe(['allow' => false, 'ttl' => 30, 'reason' => 'forbidden']) + ->and($authorizer->authorize($api, $channel)->allow)->toBeFalse(); + } +}); + +test('drivers reach only the chats they take part in', function () { + SocketAuthFixtures::database(); + + $authorizer = socket_channel_core_authorizer(); + $driver = socket_channel_driver(); + + foreach (['chat.chat_aaa', 'chat_channel.chat-a', 'chat_participant.chat_participant_a1', 'chat_message.message-a', 'user.user-a1', 'driver.driver-1'] as $channel) { + expect($authorizer->authorize($driver, $channel)->allow)->toBeTrue(); + } + + foreach (['chat.chat_bbb', 'chat_participant.participant-b1', 'chat_message.message-b', 'user.user-a2', 'company.company-a', 'file.file_aaa', 'api.cred-a'] as $channel) { + expect($authorizer->authorize($driver, $channel)->allow)->toBeFalse(); + } + + expect($authorizer->authorize(socket_channel_driver(['ids' => ['driver-1']]), 'chat.chat_aaa')->allow)->toBeFalse() + ->and($authorizer->authorize(socket_channel_driver(['ids' => []]), 'chat.chat_aaa')->allow)->toBeFalse() + ->and(CoreChannelResolvers::isChatParticipant($driver, null))->toBeFalse(); +}); diff --git a/tests/Unit/Support/SocketTokenTest.php b/tests/Unit/Support/SocketTokenTest.php new file mode 100644 index 00000000..ceddd783 --- /dev/null +++ b/tests/Unit/Support/SocketTokenTest.php @@ -0,0 +1,381 @@ +toBeNull() + ->and(SocketToken::enabled())->toBeFalse(); + + config(['broadcasting.connections.socketcluster.auth_key' => 'too-short-for-hs256']); + + expect(SocketToken::key())->toBeNull() + ->and(SocketToken::enabled())->toBeFalse(); + + config(['broadcasting.connections.socketcluster.auth_key' => SocketAuthFixtures::KEY]); + + expect(SocketToken::key())->toBe(SocketAuthFixtures::KEY) + ->and(SocketToken::enabled())->toBeTrue(); +}); + +test('issuing a socket token requires the feature to be configured', function () { + SocketAuthFixtures::container(null); + + SocketToken::issue(SocketPrincipal::system()); +})->throws(RuntimeException::class, 'Socket authentication is not configured.'); + +test('issued tokens carry the contract header and claims and verify back to the principal', function () { + SocketAuthFixtures::container(); + + $minted = SocketToken::issue(new SocketPrincipal( + kind: 'user', + sub: 'user-a1', + cid: 'company-a', + cpid: 'company_aaa', + ids: ['user-a1', 'user_a1'], + adm: true + )); + $payload = SocketAuthFixtures::payload($minted['token']); + $verified = SocketToken::verify($minted['token']); + + expect(SocketAuthFixtures::header($minted['token']))->toEqual(['alg' => 'HS256', 'typ' => 'JWT']) + ->and($minted['expires_in'])->toBe(900) + ->and($minted['expires_at'])->toBe((new DateTimeImmutable('@' . (SocketAuthFixtures::NOW + 900)))->format(DATE_ATOM)) + ->and($payload)->toMatchArray([ + 'iss' => 'fleetbase-api', + 'aud' => 'fleetbase-socket', + 'iat' => SocketAuthFixtures::NOW, + 'nbf' => SocketAuthFixtures::NOW, + 'exp' => SocketAuthFixtures::NOW + 900, + 'sub' => 'user-a1', + 'kind' => 'user', + 'cid' => 'company-a', + 'cpid' => 'company_aaa', + 'env' => 'live', + 'ids' => ['user-a1', 'user_a1'], + 'adm' => true, + ]) + ->and($payload)->not->toHaveKey('scp') + ->and($payload)->not->toHaveKey('sid') + ->and($payload['jti'])->toMatch('/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/') + ->and($verified)->toBeInstanceOf(SocketPrincipal::class) + ->and($verified->kind)->toBe('user') + ->and($verified->sub)->toBe('user-a1') + ->and($verified->cid)->toBe('company-a') + ->and($verified->cpid)->toBe('company_aaa') + ->and($verified->ids)->toBe(['user-a1', 'user_a1']) + ->and($verified->adm)->toBeTrue() + ->and($verified->scp)->toBeNull() + ->and($verified->jti)->toBe($payload['jti']) + ->and($verified->exp)->toBe(SocketAuthFixtures::NOW + 900); +}); + +test('token lifetimes follow the configured ttl per kind and stay within the socket server limit', function () { + SocketAuthFixtures::container(); + + expect(SocketToken::defaultTtl('user'))->toBe(900) + ->and(SocketToken::defaultTtl('tracking'))->toBe(1800) + ->and(SocketToken::defaultTtl('system'))->toBe(300) + ->and(SocketToken::issue(SocketPrincipal::system())['expires_in'])->toBe(300) + ->and(SocketToken::issue(SocketPrincipal::system(), 99999)['expires_in'])->toBe(3600) + ->and(SocketToken::issue(SocketPrincipal::system(), 0)['expires_in'])->toBe(1); + + config(['broadcasting.connections.socketcluster.token_ttl' => 120]); + + expect(SocketToken::defaultTtl('api'))->toBe(120); + + config(['broadcasting.connections.socketcluster.token_ttl' => 0]); + + expect(SocketToken::defaultTtl('driver'))->toBe(900); +}); + +test('verification rejects tokens that are not ours or no longer valid', function (array $header, array $claims, ?string $key) { + SocketAuthFixtures::container(); + + expect(SocketToken::verify(SocketAuthFixtures::jwt($header, $claims, $key)))->toBeNull(); +})->with([ + 'wrong key' => [['alg' => 'HS256', 'typ' => 'JWT'], SocketAuthFixtures::claims(), SocketAuthFixtures::OTHER_KEY], + 'alg none' => [['alg' => 'none', 'typ' => 'JWT'], SocketAuthFixtures::claims(), null], + 'asymmetric alg' => [['alg' => 'RS256', 'typ' => 'JWT'], SocketAuthFixtures::claims(), SocketAuthFixtures::KEY], + 'missing audience' => [['alg' => 'HS256', 'typ' => 'JWT'], array_diff_key(SocketAuthFixtures::claims(), ['aud' => true]), SocketAuthFixtures::KEY], + 'wrong audience' => [['alg' => 'HS256', 'typ' => 'JWT'], SocketAuthFixtures::claims(['aud' => 'someone-else']), SocketAuthFixtures::KEY], + 'wrong issuer' => [['alg' => 'HS256', 'typ' => 'JWT'], SocketAuthFixtures::claims(['iss' => 'someone-else']), SocketAuthFixtures::KEY], + 'missing expiry' => [['alg' => 'HS256', 'typ' => 'JWT'], array_diff_key(SocketAuthFixtures::claims(), ['exp' => true]), SocketAuthFixtures::KEY], + 'expired' => [['alg' => 'HS256', 'typ' => 'JWT'], SocketAuthFixtures::claims(['exp' => SocketAuthFixtures::NOW - 1]), SocketAuthFixtures::KEY], + 'not yet valid' => [['alg' => 'HS256', 'typ' => 'JWT'], SocketAuthFixtures::claims(['nbf' => SocketAuthFixtures::NOW + 60]), SocketAuthFixtures::KEY], + 'unknown kind claim' => [['alg' => 'HS256', 'typ' => 'JWT'], SocketAuthFixtures::claims(['kind' => 'robot']), SocketAuthFixtures::KEY], +]); + +test('verification accepts a well formed token and rejects garbage or a disabled feature', function () { + SocketAuthFixtures::container(); + + $token = SocketAuthFixtures::jwt(['alg' => 'HS256', 'typ' => 'JWT'], SocketAuthFixtures::claims(), SocketAuthFixtures::KEY); + + expect(SocketToken::verify($token))->toBeInstanceOf(SocketPrincipal::class) + ->and(SocketToken::verify($token)->jti)->toBe('jti-handmade') + ->and(SocketToken::verify(''))->toBeNull() + ->and(SocketToken::verify('not-a-jwt'))->toBeNull(); + + config(['broadcasting.connections.socketcluster.auth_key' => null]); + + expect(SocketToken::verify($token))->toBeNull(); +}); + +test('verification rejects an issued token once it has expired', function () { + SocketAuthFixtures::container(); + + $token = SocketToken::issue(SocketPrincipal::system(), 60)['token']; + + expect(SocketToken::verify($token))->toBeInstanceOf(SocketPrincipal::class); + + Carbon::setTestNow(Carbon::createFromTimestampUTC(SocketAuthFixtures::NOW + 61)); + + expect(SocketToken::verify($token))->toBeNull(); +}); + +test('system tokens are short lived and carry no company', function () { + SocketAuthFixtures::container(); + + $token = SocketToken::system(); + $principal = SocketToken::verify($token); + + expect($principal->kind)->toBe('system') + ->and($principal->isSystem())->toBeTrue() + ->and($principal->cid)->toBeNull() + ->and($principal->exp)->toBe(SocketAuthFixtures::NOW + 300) + ->and(SocketAuthFixtures::payload($token))->not->toHaveKey('cid') + ->and(SocketAuthFixtures::payload($token))->not->toHaveKey('cpid'); +}); + +test('base32 encoding follows rfc 4648 in lowercase without padding', function () { + expect(SocketToken::base32('f'))->toBe('my') + ->and(SocketToken::base32('foobar'))->toBe('mzxw6ytboi'); +}); + +test('tracking ids are the truncated base32 hmac of the tracking scope under the tracking key', function () { + SocketAuthFixtures::container(); + + // Computed independently: base32(HMAC-SHA256(hex(HMAC-SHA256(KEY, "fleetbase-socket:tracking")), msg))[:26]. + expect(SocketToken::trackingId('order-a', 'contact', 'contact-1'))->toBe('r4pb2bnb4fi2ekuheuv2qonlpp') + ->and(SocketToken::trackingId('order-a', 'contact', 'contact-2'))->not->toBe('r4pb2bnb4fi2ekuheuv2qonlpp') + ->and(SocketToken::trackingId('order-a', 'vendor', 'contact-1'))->toMatch('/^[a-z2-7]{26}$/'); +}); + +test('tracking tokens may only follow their own tracking channel', function () { + SocketAuthFixtures::container(); + + $minted = SocketToken::forTracking(new SocketTokenTrackingScope('order-a', 'contact', 'contact-1', 'company-a')); + $principal = SocketToken::verify($minted['token']); + + expect($minted['expires_in'])->toBe(1800) + ->and($principal->kind)->toBe('tracking') + ->and($principal->sub)->toBe('r4pb2bnb4fi2ekuheuv2qonlpp') + ->and($principal->cid)->toBe('company-a') + ->and($principal->scp)->toBe(['tracking.r4pb2bnb4fi2ekuheuv2qonlpp']); +}); + +test('tracking tokens take the company from the order when the scope does not carry it', function () { + SocketAuthFixtures::database(); + + $known = SocketToken::verify(SocketToken::forTracking(new SocketTokenTrackingScope('order-a', 'contact', 'contact-1'))['token']); + $unknown = SocketToken::verify(SocketToken::forTracking(new SocketTokenTrackingScope('order-missing', 'contact', 'contact-1'))['token']); + + expect($known->cid)->toBe('company-a') + ->and($unknown->cid)->toBeNull() + ->and($unknown->scp)->toHaveCount(1); +}); + +test('socket request signatures use per-purpose keys derived from the auth key', function () { + SocketAuthFixtures::container(); + + $publishKey = hash_hmac('sha256', 'fleetbase-socket:publish', SocketAuthFixtures::KEY); + $timestamp = (string) SocketAuthFixtures::NOW; + $body = '{"channels":["order.x"],"data":{}}'; + $signature = hash_hmac('sha256', $timestamp . '.' . $body, $publishKey); + $stale = (string) (SocketAuthFixtures::NOW - 61); + $edge = (string) (SocketAuthFixtures::NOW - 60); + $future = (string) (SocketAuthFixtures::NOW + 61); + + expect(SocketSignature::deriveKey(SocketSignature::PUBLISH))->toBe($publishKey) + ->and(SocketSignature::deriveKey(SocketSignature::AUTHORIZE))->toBe(hash_hmac('sha256', 'fleetbase-socket:authorize', SocketAuthFixtures::KEY)) + ->and(SocketSignature::headers(SocketSignature::PUBLISH, $body))->toBe([ + 'X-Fleetbase-Timestamp' => $timestamp, + 'X-Fleetbase-Signature' => $signature, + ]) + ->and(SocketSignature::verify(SocketSignature::PUBLISH, $timestamp, $signature, $body))->toBeTrue() + ->and(SocketSignature::verify(SocketSignature::PUBLISH, $timestamp, strtoupper($signature), $body))->toBeTrue() + ->and(SocketSignature::verify(SocketSignature::AUTHORIZE, $timestamp, $signature, $body))->toBeFalse() + ->and(SocketSignature::verify(SocketSignature::PUBLISH, $timestamp, $signature, $body . ' '))->toBeFalse() + ->and(SocketSignature::verify(SocketSignature::PUBLISH, null, $signature, $body))->toBeFalse() + ->and(SocketSignature::verify(SocketSignature::PUBLISH, 'yesterday', $signature, $body))->toBeFalse() + ->and(SocketSignature::verify(SocketSignature::PUBLISH, $timestamp, null, $body))->toBeFalse() + ->and(SocketSignature::verify(SocketSignature::PUBLISH, $timestamp, '', $body))->toBeFalse() + ->and(SocketSignature::verify(SocketSignature::PUBLISH, $stale, SocketSignature::sign(SocketSignature::PUBLISH, $stale, $body), $body))->toBeFalse() + ->and(SocketSignature::verify(SocketSignature::PUBLISH, $future, SocketSignature::sign(SocketSignature::PUBLISH, $future, $body), $body))->toBeFalse() + ->and(SocketSignature::verify(SocketSignature::PUBLISH, $edge, SocketSignature::sign(SocketSignature::PUBLISH, $edge, $body), $body))->toBeTrue(); + + config(['broadcasting.connections.socketcluster.auth_key' => null]); + + expect(SocketSignature::verify(SocketSignature::PUBLISH, $timestamp, $signature, $body))->toBeFalse(); +}); + +test('deriving a signing key requires the feature to be configured', function () { + SocketAuthFixtures::container(null); + + SocketSignature::deriveKey(SocketSignature::PUBLISH); +})->throws(RuntimeException::class, 'Socket authentication is not configured.'); + +test('socket principals reject unknown kinds, empty subjects and unknown environments', function (array $arguments) { + new SocketPrincipal(...$arguments); +})->throws(InvalidArgumentException::class)->with([ + 'unknown kind' => [['kind' => 'robot', 'sub' => 'someone']], + 'empty subject' => [['kind' => 'user', 'sub' => '']], + 'unknown environment' => [['kind' => 'user', 'sub' => 'someone', 'env' => 'staging']], +]); + +test('principals rebuild from claims and serialize back without unset claims', function () { + SocketAuthFixtures::container(); + + $principal = SocketPrincipal::fromClaims([ + 'kind' => 'customer', + 'sub' => 'contact-1', + 'cid' => 'company-a', + 'cpid' => '', + 'env' => 'test', + 'ids' => ['contact-1', '', 7, 'contact_1'], + 'adm' => 0, + 'scp' => 'storefront.store_1', + 'sid' => 'store-1', + 'jti' => 'jti-1', + 'exp' => new DateTimeImmutable('@' . (SocketAuthFixtures::NOW + 30)), + ]); + $minimal = SocketPrincipal::fromClaims(['kind' => 'api', 'sub' => 'cred-a']); + $changed = $principal->with(['env' => 'live', 'sid' => null]); + + expect($principal->cpid)->toBeNull() + ->and($principal->ids)->toBe(['contact-1', 'contact_1']) + ->and($principal->adm)->toBeFalse() + ->and($principal->scp)->toBe(['storefront.store_1']) + ->and($principal->exp)->toBe(SocketAuthFixtures::NOW + 30) + ->and($principal->secondsRemaining())->toBe(30) + ->and($principal->isCompanyScoped())->toBeFalse() + ->and($principal->isSystem())->toBeFalse() + ->and($principal->owns('contact_1'))->toBeTrue() + ->and($principal->owns('contact-2'))->toBeFalse() + ->and($principal->owns(''))->toBeFalse() + ->and($principal->toClaims())->toBe([ + 'kind' => 'customer', + 'sub' => 'contact-1', + 'cid' => 'company-a', + 'env' => 'test', + 'ids' => ['contact-1', 'contact_1'], + 'adm' => false, + 'scp' => ['storefront.store_1'], + 'sid' => 'store-1', + 'jti' => 'jti-1', + 'exp' => SocketAuthFixtures::NOW + 30, + ]) + ->and($minimal->env)->toBe('live') + ->and($minimal->ids)->toBe([]) + ->and($minimal->scp)->toBeNull() + ->and($minimal->exp)->toBeNull() + ->and($minimal->secondsRemaining())->toBeNull() + ->and($minimal->isCompanyScoped())->toBeTrue() + ->and(SocketPrincipal::fromClaims(['kind' => 'api', 'sub' => 'cred-a', 'exp' => (string) (SocketAuthFixtures::NOW + 5)])->exp)->toBe(SocketAuthFixtures::NOW + 5) + ->and($changed->kind)->toBe('customer') + ->and($changed->env)->toBe('live') + ->and($changed->sid)->toBeNull() + ->and(SocketPrincipal::system()->toClaims())->toBe([ + 'kind' => 'system', + 'sub' => 'system', + 'env' => 'live', + 'ids' => [], + 'adm' => false, + ]); +}); + +test('user principals take the company from the argument, then the session, then the user', function () { + SocketAuthFixtures::database(); + + $admin = SocketAuthFixtures::user(['type' => 'admin']); + $explicit = SocketPrincipal::forUser($admin, 'company-b'); + + session(['company' => 'company-b']); + $fromSession = SocketPrincipal::forUser(SocketAuthFixtures::user()); + session()->flush(); + + $fromUser = SocketPrincipal::forUser(SocketAuthFixtures::user()); + $unknown = SocketPrincipal::forUser(SocketAuthFixtures::user(['company_uuid' => 'company-missing', 'public_id' => null])); + $none = SocketPrincipal::forUser(SocketAuthFixtures::user(['company_uuid' => null])); + + expect($explicit->kind)->toBe('user') + ->and($explicit->sub)->toBe('user-a1') + ->and($explicit->cid)->toBe('company-b') + ->and($explicit->cpid)->toBe('company_bbb') + ->and($explicit->env)->toBe('live') + ->and($explicit->ids)->toBe(['user-a1', 'user_a1']) + ->and($explicit->adm)->toBeTrue() + ->and($fromSession->cid)->toBe('company-b') + ->and($fromSession->adm)->toBeFalse() + ->and($fromUser->cid)->toBe('company-a') + ->and($fromUser->cpid)->toBe('company_aaa') + ->and($unknown->cid)->toBe('company-missing') + ->and($unknown->cpid)->toBeNull() + ->and($unknown->ids)->toBe(['user-a1']) + ->and($none->cid)->toBeNull() + ->and($none->cpid)->toBeNull(); +}); + +test('api credential principals act in the credential environment', function () { + SocketAuthFixtures::database(); + + $live = SocketPrincipal::forApiCredential(ApiCredential::query()->find('cred-a')); + $test = SocketPrincipal::forApiCredential(ApiCredential::on('sandbox')->find('cred-a-test')); + + expect($live->kind)->toBe('api') + ->and($live->sub)->toBe('cred-a') + ->and($live->cid)->toBe('company-a') + ->and($live->cpid)->toBe('company_aaa') + ->and($live->env)->toBe('live') + ->and($live->ids)->toBe(['cred-a']) + ->and($test->sub)->toBe('cred-a-test') + ->and($test->env)->toBe('test') + ->and($test->cpid)->toBe('company_aaa'); +}); + +test('channel decisions serialize, rebuild from cache and cap their lifetime', function () { + $allowed = ChannelDecision::allowed('self'); + + expect($allowed->toArray())->toBe(['allow' => true, 'ttl' => 300, 'reason' => 'self']) + ->and(ChannelDecision::denied('forbidden')->toArray())->toBe(['allow' => false, 'ttl' => 30, 'reason' => 'forbidden']) + ->and($allowed->capTtl(42)->ttl)->toBe(42) + ->and($allowed->capTtl(0)->ttl)->toBe(1) + ->and($allowed->capTtl(900)->ttl)->toBe(300) + ->and(ChannelDecision::fromArray(['allow' => true, 'ttl' => 12, 'reason' => 'cached'])->toArray())->toBe(['allow' => true, 'ttl' => 12, 'reason' => 'cached']) + ->and(ChannelDecision::fromArray([])->toArray())->toBe(['allow' => false, 'ttl' => 30, 'reason' => 'denied']); +}); From 7e6f3c615acbd701177ab9b25ee422d70d772b05 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 15:01:44 +0800 Subject: [PATCH 06/51] feat(socket-auth): socket token and channel authorize endpoints - POST int/v1/socket/token (console session): a user token for the current company, in the sandbox environment when the console is in sandbox mode. - POST v1/socket/token (public API): an api token for an API credential; for a Sanctum user token, the principal a registered resolver claims, else a user token. - POST int/v1/socket/authorize: called by the socket server only, admitted by its signature (VerifySocketSignature) rather than a session; re-verifies the token and returns {allow, ttl, reason}. Exempt from the configured-instance check so an install page can follow the install channel before setup ends. - Every mint route answers 404 while SOCKETCLUSTER_AUTH_KEY is unset. --- src/Http/Controllers/SocketAuthController.php | 113 +++++++++++ .../Middleware/EnsureFleetbaseConfigured.php | 6 + src/Http/Middleware/VerifySocketSignature.php | 36 ++++ src/routes.php | 8 + tests/Unit/Http/MiddlewareContractsTest.php | 18 ++ tests/Unit/Http/SocketAuthControllerTest.php | 178 ++++++++++++++++++ tests/Unit/RoutesContractTest.php | 18 ++ 7 files changed, 377 insertions(+) create mode 100644 src/Http/Controllers/SocketAuthController.php create mode 100644 src/Http/Middleware/VerifySocketSignature.php create mode 100644 tests/Unit/Http/SocketAuthControllerTest.php diff --git a/src/Http/Controllers/SocketAuthController.php b/src/Http/Controllers/SocketAuthController.php new file mode 100644 index 00000000..82315838 --- /dev/null +++ b/src/Http/Controllers/SocketAuthController.php @@ -0,0 +1,113 @@ +user(); + + if (!$user instanceof User) { + return response()->json(['error' => 'Unauthenticated.'], 401); + } + + $principal = SocketPrincipal::forUser($user); + + // The console's sandbox toggle reads and writes the sandbox database. + if (Utils::isTrue($request->header('Access-Console-Sandbox'))) { + $principal = $principal->with(['env' => 'test']); + } + + return response()->json(SocketToken::issue($principal)); + } + + /** + * POST v1/socket/token: an api token for an API credential; for a Sanctum user token, + * the principal a registered resolver claims (a driver, for FleetOps) or else a user token. + */ + public function apiToken(Request $request) + { + if (!SocketToken::enabled()) { + return static::disabled(); + } + + $bearer = $request->bearerToken(); + $personalAccessToken = $bearer ? PersonalAccessToken::findToken($bearer) : null; + + if ($personalAccessToken !== null && $personalAccessToken->tokenable instanceof User) { + $user = $personalAccessToken->tokenable; + $principal = app(SocketChannelRegistry::class)->resolvePrincipal($request, $user) ?? SocketPrincipal::forUser($user, $user->company_uuid); + + return response()->json(SocketToken::issue($principal)); + } + + $credential = Auth::getApiKey(); + + if ($credential === null) { + return response()->json(['error' => 'Unauthenticated.'], 401); + } + + return response()->json(SocketToken::issue(SocketPrincipal::forApiCredential($credential))); + } + + /** + * A system token for a platform API caller. + */ + public function systemToken(Request $request) + { + if (!SocketToken::enabled()) { + return static::disabled(); + } + + return response()->json(SocketToken::issue(SocketPrincipal::system())); + } + + /** + * POST int/v1/socket/authorize: the socket server asks whether a token may subscribe to a channel. + * + * The token is verified here again; nothing the socket server derived from it is trusted. + */ + public function authorizeChannel(Request $request) + { + $token = $request->input('token'); + $channel = $request->input('channel'); + $hasToken = is_string($token) && $token !== ''; + $principal = $hasToken ? SocketToken::verify($token) : null; + $decision = app(ChannelAuthorizer::class)->authorize($principal, is_string($channel) ? $channel : ''); + + if ($hasToken && $principal === null && !$decision->allow) { + $decision = ChannelDecision::denied('invalid_token'); + } + + return response()->json($decision->toArray()); + } + + protected static function disabled() + { + return response()->json(['error' => 'Not Found'], 404); + } +} diff --git a/src/Http/Middleware/EnsureFleetbaseConfigured.php b/src/Http/Middleware/EnsureFleetbaseConfigured.php index c6b8a3bc..e24329c7 100644 --- a/src/Http/Middleware/EnsureFleetbaseConfigured.php +++ b/src/Http/Middleware/EnsureFleetbaseConfigured.php @@ -44,6 +44,12 @@ protected function shouldCheck(Request $request): bool return false; } + // The socket server asks this endpoint whether an anonymous install page may follow + // the install channel, which is exactly the case before setup has finished. + if ($request->is('int/v1/socket/authorize') || $request->is('*/int/v1/socket/authorize')) { + return false; + } + return $request->is('int/*') || $request->is('*/int/*') || $request->is('v1/*') diff --git a/src/Http/Middleware/VerifySocketSignature.php b/src/Http/Middleware/VerifySocketSignature.php new file mode 100644 index 00000000..ff7ea394 --- /dev/null +++ b/src/Http/Middleware/VerifySocketSignature.php @@ -0,0 +1,36 @@ +json(['error' => 'Not Found'], 404); + } + + $valid = SocketSignature::verify( + SocketSignature::AUTHORIZE, + $request->header(SocketSignature::HEADER_TIMESTAMP), + $request->header(SocketSignature::HEADER_SIGNATURE), + $request->getContent() + ); + + if (!$valid) { + return response()->json(['error' => 'invalid_signature'], 401); + } + + return $next($request); + } +} diff --git a/src/routes.php b/src/routes.php index fe76c767..ca1b9638 100644 --- a/src/routes.php +++ b/src/routes.php @@ -41,6 +41,8 @@ function ($router) { ->namespace('Api\v1') ->middleware(['fleetbase.api']) ->group(function ($router) { + // Realtime socket token for an API credential or a Sanctum user token. + $router->post('socket/token', [Fleetbase\Http\Controllers\SocketAuthController::class, 'apiToken']); $router->group( ['prefix' => 'organizations'], function ($router) { @@ -163,6 +165,10 @@ function ($router) { $router->get('branding', 'SettingController@getBrandingSettings'); } ); + // Called by the socket server only: authenticated by its request signature, + // never by a session or user token. + $router->post('socket/authorize', [Fleetbase\Http\Controllers\SocketAuthController::class, 'authorizeChannel']) + ->middleware(Fleetbase\Http\Middleware\VerifySocketSignature::class); $router->group( ['prefix' => 'two-fa', 'middleware' => [Fleetbase\Http\Middleware\ThrottleRequests::class]], function ($router) { @@ -176,6 +182,8 @@ function ($router) { $router->group( ['middleware' => ['fleetbase.protected']], function ($router) { + // Realtime socket token for the signed-in console user. + $router->post('socket/token', [Fleetbase\Http\Controllers\SocketAuthController::class, 'token']); $router->group( ['prefix' => 'lookup'], function ($router) { diff --git a/tests/Unit/Http/MiddlewareContractsTest.php b/tests/Unit/Http/MiddlewareContractsTest.php index 952a0f49..111efe5f 100644 --- a/tests/Unit/Http/MiddlewareContractsTest.php +++ b/tests/Unit/Http/MiddlewareContractsTest.php @@ -536,6 +536,24 @@ function middleware_contracts_log_middleware(bool $enabled = true): LogApiReques ->and($options->getData(true))->toBe(['ok' => true]); }); + test('ensure fleetbase configured lets the socket server authorize the install channel before setup', function () { + middleware_contracts_fixture(); + + $middleware = middleware_contracts_configured_middleware(null, [], true); + $internal = $middleware->handle( + middleware_contracts_request('/int/v1/socket/authorize', 'int/v1/socket/authorize'), + fn () => new JsonResponse(['authorized' => true]) + ); + $prefixed = $middleware->handle( + middleware_contracts_request('/api/int/v1/socket/authorize', 'api/int/v1/socket/authorize'), + fn () => new JsonResponse(['authorized' => true]) + ); + + expect($internal->getStatusCode())->toBe(200) + ->and($internal->getData(true))->toBe(['authorized' => true]) + ->and($prefixed->getStatusCode())->toBe(200); + }); + test('ensure fleetbase configured returns setup error when database or core tables are missing', function () { middleware_contracts_fixture(); diff --git a/tests/Unit/Http/SocketAuthControllerTest.php b/tests/Unit/Http/SocketAuthControllerTest.php new file mode 100644 index 00000000..19cdb530 --- /dev/null +++ b/tests/Unit/Http/SocketAuthControllerTest.php @@ -0,0 +1,178 @@ +instance(SocketChannelRegistry::class, $registry); + app()->instance(ChannelAuthorizer::class, new ChannelAuthorizer($registry)); + + return $registry; +} + +function socket_auth_controller_request(string $uri, array $server = [], ?string $content = null): Request +{ + return Request::create($uri, 'POST', [], [], [], array_merge(['CONTENT_TYPE' => 'application/json'], $server), $content); +} + +function socket_auth_controller_principal(JsonResponse $response): ?SocketPrincipal +{ + return SocketToken::verify($response->getData(true)['token']); +} + +afterEach(function () { + SocketAuthFixtures::reset(); +}); + +test('every token route answers 404 while socket authentication is not configured', function () { + SocketAuthFixtures::container(null); + + $controller = new SocketAuthController(); + $request = socket_auth_controller_request('/int/v1/socket/token'); + + foreach ([$controller->token($request), $controller->apiToken($request), $controller->systemToken($request)] as $response) { + expect($response->getStatusCode())->toBe(404) + ->and($response->getData(true))->toBe(['error' => 'Not Found']); + } +}); + +test('console sessions receive a user token for their company and environment', function () { + SocketAuthFixtures::database(); + + $controller = new SocketAuthController(); + $unauthenticated = $controller->token(socket_auth_controller_request('/int/v1/socket/token')); + + $request = socket_auth_controller_request('/int/v1/socket/token'); + $request->setUserResolver(fn () => User::query()->find('user-a1')); + $response = $controller->token($request); + + $sandboxRequest = socket_auth_controller_request('/int/v1/socket/token', ['HTTP_ACCESS_CONSOLE_SANDBOX' => 'true']); + $sandboxRequest->setUserResolver(fn () => User::query()->find('user-a1')); + $sandbox = socket_auth_controller_principal($controller->token($sandboxRequest)); + + $principal = socket_auth_controller_principal($response); + + expect($unauthenticated->getStatusCode())->toBe(401) + ->and($response->getStatusCode())->toBe(200) + ->and($response->getData(true))->toHaveKeys(['token', 'expires_in', 'expires_at']) + ->and($response->getData(true)['expires_in'])->toBe(900) + ->and($principal->kind)->toBe('user') + ->and($principal->sub)->toBe('user-a1') + ->and($principal->cid)->toBe('company-a') + ->and($principal->cpid)->toBe('company_aaa') + ->and($principal->env)->toBe('live') + ->and($sandbox->env)->toBe('test'); +}); + +test('api clients receive an api, user or registered principal token', function () { + SocketAuthFixtures::database(); + + $registry = socket_auth_controller_registry(); + $controller = new SocketAuthController(); + $userToken = socket_auth_controller_request('/v1/socket/token', ['HTTP_AUTHORIZATION' => 'Bearer plain-token-a1']); + $user = socket_auth_controller_principal($controller->apiToken($userToken)); + + $registry->registerPrincipalResolver(function (Request $request, User $user) { + return new SocketPrincipal(kind: 'driver', sub: 'driver-1', cid: $user->company_uuid, ids: ['driver-1', $user->uuid]); + }); + $driver = socket_auth_controller_principal($controller->apiToken($userToken)); + + session(['api_credential' => 'cred-a']); + $credential = socket_auth_controller_principal($controller->apiToken(socket_auth_controller_request('/v1/socket/token', ['HTTP_AUTHORIZATION' => 'Bearer flb_live_a']))); + session()->flush(); + + $anonymous = $controller->apiToken(socket_auth_controller_request('/v1/socket/token')); + + expect($user->kind)->toBe('user') + ->and($user->sub)->toBe('user-a1') + ->and($user->cid)->toBe('company-a') + ->and($driver->kind)->toBe('driver') + ->and($driver->ids)->toBe(['driver-1', 'user-a1']) + ->and($credential->kind)->toBe('api') + ->and($credential->sub)->toBe('cred-a') + ->and($credential->env)->toBe('live') + ->and($anonymous->getStatusCode())->toBe(401); +}); + +test('platform callers receive a short lived system token', function () { + SocketAuthFixtures::container(); + + $response = (new SocketAuthController())->systemToken(socket_auth_controller_request('/v1/socket/token')); + $principal = socket_auth_controller_principal($response); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getData(true)['expires_in'])->toBe(300) + ->and($principal->isSystem())->toBeTrue(); +}); + +test('the authorize endpoint re-verifies the token and answers with a cacheable decision', function () { + SocketAuthFixtures::database(); + socket_auth_controller_registry(); + + $controller = new SocketAuthController(); + $token = SocketToken::issue(SocketPrincipal::forUser(User::query()->find('user-a1')))['token']; + $ask = function (array $body) use ($controller) { + return $controller->authorizeChannel(socket_auth_controller_request('/int/v1/socket/authorize', [], json_encode($body)))->getData(true); + }; + + expect($ask(['token' => $token, 'channel' => 'chat.chat_aaa']))->toBe(['allow' => true, 'ttl' => 300, 'reason' => 'resolver']) + ->and($ask(['token' => $token, 'channel' => 'chat.chat_bbb']))->toBe(['allow' => false, 'ttl' => 30, 'reason' => 'forbidden']) + ->and($ask(['token' => $token . 'x', 'channel' => 'chat.chat_aaa']))->toBe(['allow' => false, 'ttl' => 30, 'reason' => 'invalid_token']) + ->and($ask(['token' => null, 'channel' => 'company.company_aaa']))->toBe(['allow' => false, 'ttl' => 30, 'reason' => 'no_token']) + ->and($ask(['token' => $token, 'channel' => ['not', 'a', 'string']]))->toBe(['allow' => false, 'ttl' => 30, 'reason' => 'invalid_channel']); +}); + +test('the authorize endpoint only admits requests signed with the authorize key', function () { + SocketAuthFixtures::container(); + + $middleware = new VerifySocketSignature(); + $body = '{"token":null,"channel":"fleetbase.install"}'; + $now = (string) SocketAuthFixtures::NOW; + $stale = (string) (SocketAuthFixtures::NOW - 120); + $next = function () { + return new JsonResponse(['passed' => true]); + }; + $send = function (?string $timestamp, ?string $signature) use ($middleware, $body, $next) { + $headers = array_filter([ + 'HTTP_X_FLEETBASE_TIMESTAMP' => $timestamp, + 'HTTP_X_FLEETBASE_SIGNATURE' => $signature, + ]); + + return $middleware->handle(socket_auth_controller_request('/int/v1/socket/authorize', $headers, $body), $next); + }; + + $signature = SocketSignature::sign(SocketSignature::AUTHORIZE, $now, $body); + $good = $send($now, $signature); + $rejected = [ + $send($now, SocketSignature::sign(SocketSignature::PUBLISH, $now, $body)), + $send($stale, SocketSignature::sign(SocketSignature::AUTHORIZE, $stale, $body)), + $send($now, str_repeat('0', 64)), + $send(null, null), + ]; + + expect($good->getStatusCode())->toBe(200) + ->and($good->getData(true))->toBe(['passed' => true]); + + foreach ($rejected as $response) { + expect($response->getStatusCode())->toBe(401) + ->and($response->getData(true))->toBe(['error' => 'invalid_signature']); + } + + config(['broadcasting.connections.socketcluster.auth_key' => null]); + + expect($send($now, $signature)->getStatusCode())->toBe(404); +}); diff --git a/tests/Unit/RoutesContractTest.php b/tests/Unit/RoutesContractTest.php index f7e8fe2e..569b6550 100644 --- a/tests/Unit/RoutesContractTest.php +++ b/tests/Unit/RoutesContractTest.php @@ -365,6 +365,24 @@ function routes_contract_index(array $rows, string $method, string $uri): int|fa ->toBe('Fleetbase\Http\Controllers\Internal\v1\NotificationController@registry'); }); + test('route file exposes socket token minting per client type and a signature-only authorize endpoint', function () { + $routes = routes_contract_rows(routes_contract_router()); + $controller = 'Fleetbase\\Http\\Controllers\\SocketAuthController'; + + $consoleToken = routes_contract_find($routes, 'POST', 'int/v1/socket/token'); + $apiToken = routes_contract_find($routes, 'POST', 'v1/socket/token'); + $authorize = routes_contract_find($routes, 'POST', 'int/v1/socket/authorize'); + + expect($consoleToken['action'])->toBe($controller . '@token') + ->and($consoleToken['middleware'])->toContain('fleetbase.protected') + ->and($apiToken['action'])->toBe($controller . '@apiToken') + ->and($apiToken['middleware'])->toContain('fleetbase.api') + ->and($apiToken['middleware'])->not->toContain('fleetbase.platform-api') + // Only the socket server calls this; its signature is the whole of its authentication. + ->and($authorize['action'])->toBe($controller . '@authorizeChannel') + ->and($authorize['middleware'])->toBe([Fleetbase\Http\Middleware\VerifySocketSignature::class]); + }); + test('route file exposes api rate limit administration as protected routes', function () { $routes = routes_contract_rows(routes_contract_router()); $controller = 'Fleetbase\\Http\\Controllers\\Internal\\v1\\RateLimitController'; From 5b13f216c415484f46060af8cdb58b414f3e7035 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 15:01:51 +0800 Subject: [PATCH 07/51] feat(socket-auth): publish broadcasts with one signed HTTP request When SOCKETCLUSTER_AUTH_KEY is set, the broadcaster and SocketClusterService::publish()/send() post every channel of a broadcast in a single request to {SOCKETCLUSTER_PUBLISH_URL}/publish, signed with the derived publish key and short timeouts. Without the key the websocket publisher is used as before. Channels ending in "." (an empty suffix, e.g. a session read in a queue worker) and names the socket server would reject are dropped before publishing. --- .../SocketClusterBroadcaster.php | 7 +- .../SocketCluster/SocketClusterService.php | 100 ++++++++++++++ .../Support/SocketClusterHttpPublishTest.php | 130 ++++++++++++++++++ 3 files changed, 234 insertions(+), 3 deletions(-) create mode 100644 tests/Unit/Support/SocketClusterHttpPublishTest.php diff --git a/src/Support/SocketCluster/SocketClusterBroadcaster.php b/src/Support/SocketCluster/SocketClusterBroadcaster.php index fa6d6526..fb621e54 100644 --- a/src/Support/SocketCluster/SocketClusterBroadcaster.php +++ b/src/Support/SocketCluster/SocketClusterBroadcaster.php @@ -42,14 +42,15 @@ public function validAuthenticationResponse($request, $result) /** * Broadcast. * + * Channels with an empty suffix are dropped; when signed publishing is configured every + * channel goes out in a single request. + * * @param string $event * * @return void */ public function broadcast(array $channels, $event, array $payload = []) { - foreach ($channels as $channel) { - $this->socketcluster->send($channel, $payload); - } + $this->socketcluster->sendMany($channels, $payload); } } diff --git a/src/Support/SocketCluster/SocketClusterService.php b/src/Support/SocketCluster/SocketClusterService.php index a3596821..af83c187 100644 --- a/src/Support/SocketCluster/SocketClusterService.php +++ b/src/Support/SocketCluster/SocketClusterService.php @@ -2,12 +2,17 @@ namespace Fleetbase\Support\SocketCluster; +use Illuminate\Support\Facades\Http; use WebSocket\Client; /** * Class SocketClusterService. * * Service class for managing SocketCluster connections and messages. + * + * With SOCKETCLUSTER_AUTH_KEY configured, messages are published with one signed HTTP + * request to the socket server's internal publish endpoint. Without it they are sent over + * the websocket as before. */ class SocketClusterService { @@ -152,6 +157,10 @@ public static function publish($channel, array $data = [], $options = []): bool */ public function send($channel, array $data = []): bool { + if (static::publishesOverHttp()) { + return $this->sendMany([$channel], $data); + } + $cid = rand(); $message = new SocketClusterMessage($channel, $data, $cid); $this->sent = false; @@ -173,6 +182,97 @@ public function send($channel, array $data = []): bool return $this->sent; } + /** + * Sends one message to several channels. + * + * Channels with an empty suffix (for example "company." from a session read in a queue + * worker) are dropped. Over HTTP all channels go in a single request; over the websocket + * each channel is sent in turn. Returns true when every send succeeded. + */ + public function sendMany(array $channels, array $data = []): bool + { + $channels = static::filterChannels($channels); + + if ($channels === []) { + return true; + } + + if (static::publishesOverHttp()) { + return $this->publishOverHttp($channels, $data); + } + + $sent = true; + + foreach ($channels as $channel) { + $sent = $this->send($channel, $data) && $sent; + } + + return $sent; + } + + /** + * Normalizes channels to unique names, dropping those ending in "." and any the socket + * server would reject (empty, longer than 255 characters or containing whitespace). + */ + public static function filterChannels(array $channels): array + { + $names = array_map(fn ($channel) => trim((string) $channel), $channels); + + return array_values(array_unique(array_filter($names, fn ($name) => ChannelAuthorizer::isValidChannel($name) && !str_ends_with($name, '.')))); + } + + /** + * Whether messages are published over the signed HTTP endpoint rather than the websocket. + */ + public static function publishesOverHttp(): bool + { + return SocketToken::enabled(); + } + + /** + * The socket server's internal publish endpoint. + */ + public static function publishUrl(): string + { + $base = config('broadcasting.connections.socketcluster.publish_url'); + + if (!is_string($base) || $base === '') { + $base = 'http://' . config('broadcasting.connections.socketcluster.options.host', 'socket') . ':8001'; + } + + return rtrim($base, '/') . '/publish'; + } + + /** + * Publishes to all channels with one request signed by the derived publish key. + */ + protected function publishOverHttp(array $channels, array $data): bool + { + $this->sent = false; + $this->error = null; + + try { + $body = json_encode(['channels' => $channels, 'data' => $data === [] ? new \stdClass() : $data], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $response = Http::withHeaders(SocketSignature::headers(SocketSignature::PUBLISH, $body)) + ->withBody($body, 'application/json') + ->acceptJson() + ->connectTimeout(2) + ->timeout(3) + ->post(static::publishUrl()); + + $this->response = $response->body(); + $this->sent = $response->successful(); + + if (!$this->sent) { + $this->error = 'Socket publish failed with HTTP status ' . $response->status() . '.'; + } + } catch (\Throwable $e) { + $this->error = $e->getMessage(); + } + + return $this->sent; + } + /** * Sends a handshake message to the SocketCluster server. * diff --git a/tests/Unit/Support/SocketClusterHttpPublishTest.php b/tests/Unit/Support/SocketClusterHttpPublishTest.php new file mode 100644 index 00000000..a6584276 --- /dev/null +++ b/tests/Unit/Support/SocketClusterHttpPublishTest.php @@ -0,0 +1,130 @@ +sentMessages[] = [$channel, $data]; + + return true; + } +} + +function socket_cluster_http_service(): SocketClusterService +{ + return new SocketClusterService(['secure' => false, 'host' => 'socket.test', 'port' => 8000, 'path' => '/socketcluster/']); +} + +afterEach(function () { + SocketAuthFixtures::reset(); +}); + +test('broadcasts go out as one signed publish request without empty-suffix channels', function () { + SocketAuthFixtures::container(); + Http::fake(['*' => Http::response(['published' => 2], 202)]); + + $service = socket_cluster_http_service(); + (new SocketClusterBroadcaster($service))->broadcast(['order.order_1', 'company.', 'api.', 'order.order_1', new Channel('company.company_aaa'), ''], 'order.updated', ['id' => 'order_1']); + + $publishKey = hash_hmac('sha256', 'fleetbase-socket:publish', SocketAuthFixtures::KEY); + + Http::assertSentCount(1); + Http::assertSent(function (HttpRequest $request) use ($publishKey) { + $timestamp = $request->header('X-Fleetbase-Timestamp')[0]; + + return $request->url() === 'http://socket.test:8001/publish' + && $request->method() === 'POST' + && $request->body() === '{"channels":["order.order_1","company.company_aaa"],"data":{"id":"order_1"}}' + && $timestamp === (string) SocketAuthFixtures::NOW + && $request->header('X-Fleetbase-Signature')[0] === hash_hmac('sha256', $timestamp . '.' . $request->body(), $publishKey); + }); + + expect($service->response())->toBe('{"published":2}') + ->and($service->error())->toBeNull(); +}); + +test('the static publish api and single sends use the signed endpoint when configured', function () { + SocketAuthFixtures::container(SocketAuthFixtures::KEY, [ + 'broadcasting.connections.socketcluster.publish_url' => 'http://socket.test:8001/', + ]); + Http::fake(['*' => Http::response(['published' => 1], 202)]); + + expect(SocketClusterService::publish('company.company_aaa', ['event' => 'updated']))->toBeTrue() + ->and(socket_cluster_http_service()->send('chat.chat_aaa'))->toBeTrue() + ->and(socket_cluster_http_service()->send('company.'))->toBeTrue(); + + Http::assertSentCount(2); + Http::assertSent(fn (HttpRequest $request) => $request->body() === '{"channels":["company.company_aaa"],"data":{"event":"updated"}}'); + Http::assertSent(fn (HttpRequest $request) => $request->body() === '{"channels":["chat.chat_aaa"],"data":{}}'); +}); + +test('failed signed publishes report the error without throwing', function () { + SocketAuthFixtures::container(); + Http::fake(['*' => Http::response('unauthorized', 401)]); + + $rejected = socket_cluster_http_service(); + + expect($rejected->sendMany(['order.order_1'], ['id' => 'order_1']))->toBeFalse() + ->and($rejected->error())->toBe('Socket publish failed with HTTP status 401.') + ->and($rejected->response())->toBe('unauthorized'); + + // A fresh factory: stubs accumulate, so the 401 stub above would otherwise still match first. + Http::swap(new HttpFactory()); + Http::fake(function () { + throw new RuntimeException('socket server unreachable'); + }); + + $unreachable = socket_cluster_http_service(); + + expect($unreachable->sendMany(['order.order_1']))->toBeFalse() + ->and($unreachable->error())->not->toBeNull(); +}); + +test('without an auth key broadcasts keep using the websocket publisher per channel', function () { + SocketAuthFixtures::container(null); + Http::fake(); + + $service = new SocketClusterHttpPublishWebsocketRecorder(); + (new SocketClusterBroadcaster($service))->broadcast(['company.company_aaa', 'api.', 'user.user_a1'], 'user.updated', ['id' => 'user_a1']); + + expect($service->sentMessages)->toBe([ + ['company.company_aaa', ['id' => 'user_a1']], + ['user.user_a1', ['id' => 'user_a1']], + ]) + ->and($service->sendMany(['company.', ' ']))->toBeTrue() + ->and(SocketClusterService::publishesOverHttp())->toBeFalse(); + + Http::assertNothingSent(); +}); + +test('publish urls come from config with a fallback to the socket host internal port', function () { + SocketAuthFixtures::container(); + + expect(SocketClusterService::publishUrl())->toBe('http://socket.test:8001/publish') + ->and(SocketClusterService::filterChannels(['a.b', new Channel('c.d'), 'a.b', 'e.', ' ', 'f g', str_repeat('h', 256)]))->toBe(['a.b', 'c.d']); + + config(['broadcasting.connections.socketcluster.publish_url' => '']); + + expect(SocketClusterService::publishUrl())->toBe('http://socket.test:8001/publish'); + + config([ + 'broadcasting.connections.socketcluster.publish_url' => null, + 'broadcasting.connections.socketcluster.options.host' => 'realtime.internal', + ]); + + expect(SocketClusterService::publishUrl())->toBe('http://realtime.internal:8001/publish'); +}); From 819aa3812dd50d5574c53a9b1bc9e54959306e15 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 15:01:51 +0800 Subject: [PATCH 08/51] fix(socket-auth): admin socket test publishes only to the admin's own channel The SocketCluster settings test ignored nothing: any admin could publish an arbitrary payload to any channel. It now always publishes to test.{current user uuid} and returns that channel so the console can subscribe to it. --- .../Internal/v1/SettingController.php | 16 ++++++++++- .../SettingControllerExternalProbesTest.php | 28 +++++++++++++++++-- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/Http/Controllers/Internal/v1/SettingController.php b/src/Http/Controllers/Internal/v1/SettingController.php index ac994e1c..df5a00e4 100644 --- a/src/Http/Controllers/Internal/v1/SettingController.php +++ b/src/Http/Controllers/Internal/v1/SettingController.php @@ -1040,14 +1040,28 @@ public function testSentryConfig(AdminRequest $request) /** * Test SocketCluster Configuration. * + * Publishes only to the signed-in user's own `test.{user uuid}` channel; any channel in the + * request is ignored. The channel used is returned so the console can subscribe to it. + * * @param Request $request the incoming HTTP request containing the authenticated user * * @return \Illuminate\Http\JsonResponse returns a JSON response with a success message and HTTP status 200 */ public function testSocketcluster(AdminRequest $request) { + $userUuid = session('user'); + + if (!is_string($userUuid) || $userUuid === '') { + return response()->json([ + 'status' => 'error', + 'message' => 'No signed-in user to publish the test message for.', + 'channel' => null, + 'response' => null, + ]); + } + // Get the channel to publish to - $channel = $request->input('channel', 'test'); + $channel = 'test.' . $userUuid; $message = 'Socket broadcasted message successfully.'; $status = 'success'; $sent = false; diff --git a/tests/Unit/Http/SettingControllerExternalProbesTest.php b/tests/Unit/Http/SettingControllerExternalProbesTest.php index 4f6c04ee..f3948b38 100644 --- a/tests/Unit/Http/SettingControllerExternalProbesTest.php +++ b/tests/Unit/Http/SettingControllerExternalProbesTest.php @@ -28,7 +28,8 @@ function setting_controller_external_probe_fixtures(array $config = []): void 'token' => 'existing-token', 'from' => '+15555550100', ], - 'broadcasting.connections.socketcluster.options' => [ + 'broadcasting.connections.socketcluster.auth_key' => null, + 'broadcasting.connections.socketcluster.options' => [ 'secure' => false, 'host' => '127.0.0.1', 'port' => 9, @@ -170,16 +171,37 @@ function setting_controller_external_probe_request(array $input = []): AdminRequ test('test socketcluster returns stable json when the configured socket cannot send', function () { setting_controller_external_probe_fixtures(); + session(['user' => 'user-probe']); + // Any requested channel is ignored: the probe only ever publishes to the admin's own test channel. $response = (new SettingController())->testSocketcluster(setting_controller_external_probe_request([ - 'channel' => 'settings-probe', + 'channel' => 'company.someone-else', ])); + session()->flush(); + expect($response->getStatusCode())->toBe(200) ->and($response->getData(true))->toBe([ 'status' => 'error', 'message' => 'Socket broadcasted message successfully.', - 'channel' => 'settings-probe', + 'channel' => 'test.user-probe', + 'response' => null, + ]); +}); + +test('test socketcluster refuses to publish without a signed-in user', function () { + setting_controller_external_probe_fixtures(); + session()->flush(); + + $response = (new SettingController())->testSocketcluster(setting_controller_external_probe_request([ + 'channel' => 'company.someone-else', + ])); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getData(true))->toBe([ + 'status' => 'error', + 'message' => 'No signed-in user to publish the test message for.', + 'channel' => null, 'response' => null, ]); }); From 3222eb41f3e261c1371e0c51147f1d4d6d799328 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 15:01:51 +0800 Subject: [PATCH 09/51] docs(socket-auth): document realtime channel authentication --- README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/README.md b/README.md index c0adb0e6..de43866d 100644 --- a/README.md +++ b/README.md @@ -109,3 +109,35 @@ Notes: - Transformers may return `MissingValue` / `MergeValue` objects; they are filtered like `when()` / `merge()` output. Keys excluded with `without()` stay excluded. - Re-registering a class replaces its options; `ResourceTransformerRegistry::forget()` and `reset()` remove registrations. - The registry is a container singleton (`app(ResourceTransformerRegistry::class)`); registrations happen at boot and are shared by every request in an Octane worker. + +## Realtime channel authentication + +Setting `SOCKETCLUSTER_AUTH_KEY` (a shared secret of at least 32 characters, also given to the socket server) turns on authenticated realtime channels. Without it nothing changes: no socket tokens are minted, the token routes answer 404, and broadcasts use the websocket publisher as before. + +| Variable | Default | Meaning | +|---|---|---| +| `SOCKETCLUSTER_AUTH_KEY` | unset | Signs socket tokens (HS256) and, through derived keys, the API to socket server requests. | +| `SOCKETCLUSTER_PUBLISH_URL` | `http://{SOCKETCLUSTER_HOST}:8001` | The socket server's internal listener; broadcasts are sent as one signed `POST {url}/publish`. | +| `SOCKETCLUSTER_TOKEN_TTL` | `900` | Lifetime in seconds of user, API, driver, customer and checkout tokens. | + +Clients fetch a token before connecting: `POST int/v1/socket/token` (console session), `POST v1/socket/token` (API credential or Sanctum user token). The socket server asks `POST int/v1/socket/authorize`, signed with its own derived key, whether a token may subscribe to a channel. + +A channel is authorized by the resolver registered for its prefix (the part before the first `.`); unknown prefixes are denied. Extensions register theirs from their service provider: + +```php +use Fleetbase\Support\SocketCluster\SocketChannelRegistry; +use Fleetbase\Support\SocketCluster\SocketPrincipal; + +$registry = app(SocketChannelRegistry::class); + +// `order.{uuid|public_id}`: users and API credentials of the order's company; drivers only when $narrow agrees. +$registry->registerModel('order', Order::class, fn (SocketPrincipal $p, Order $order) => $p->kind === 'driver' && $p->owns((string) $order->driver_assigned_uuid)); + +// Anything else: fn (SocketPrincipal $p, string $id, string $channel): bool +$registry->register('fleet', fn (SocketPrincipal $p, string $id, string $channel) => /* ... */ false); + +// Claim a Sanctum-authenticated user as a more specific principal on `POST v1/socket/token`. +$registry->registerPrincipalResolver(fn (Request $request, $user) => /* ?SocketPrincipal */ null); +``` + +`app(ChannelAuthorizer::class)->authorize($principal, $channel)` gives the same decision anywhere in PHP. From f2e142ff3d91bc39ca7977b06d932aaf957fa80e Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 15:03:48 +0800 Subject: [PATCH 10/51] feat(socket-auth): system socket token route for platform API callers POST v1/socket/system-token in the fleetbase.platform-api group mints a system token. A separate path because the platform and public API groups share the v1 prefix, where v1/socket/token is the public API mint route. --- src/routes.php | 2 ++ tests/Unit/RoutesContractTest.php | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/src/routes.php b/src/routes.php index ca1b9638..4d51c2b5 100644 --- a/src/routes.php +++ b/src/routes.php @@ -35,6 +35,8 @@ function ($router) { ->middleware(['fleetbase.platform-api']) ->group(function ($router) { $router->get('organizations', 'OrganizationController@listOrganizations'); + // Realtime socket token for the platform itself. + $router->post('socket/system-token', [Fleetbase\Http\Controllers\SocketAuthController::class, 'systemToken']); }); $router->prefix('v1') diff --git a/tests/Unit/RoutesContractTest.php b/tests/Unit/RoutesContractTest.php index 569b6550..a130adec 100644 --- a/tests/Unit/RoutesContractTest.php +++ b/tests/Unit/RoutesContractTest.php @@ -371,6 +371,7 @@ function routes_contract_index(array $rows, string $method, string $uri): int|fa $consoleToken = routes_contract_find($routes, 'POST', 'int/v1/socket/token'); $apiToken = routes_contract_find($routes, 'POST', 'v1/socket/token'); + $systemToken = routes_contract_find($routes, 'POST', 'v1/socket/system-token'); $authorize = routes_contract_find($routes, 'POST', 'int/v1/socket/authorize'); expect($consoleToken['action'])->toBe($controller . '@token') @@ -378,6 +379,9 @@ function routes_contract_index(array $rows, string $method, string $uri): int|fa ->and($apiToken['action'])->toBe($controller . '@apiToken') ->and($apiToken['middleware'])->toContain('fleetbase.api') ->and($apiToken['middleware'])->not->toContain('fleetbase.platform-api') + ->and($systemToken['action'])->toBe($controller . '@systemToken') + ->and($systemToken['middleware'])->toContain('fleetbase.platform-api') + ->and($systemToken['middleware'])->not->toContain('fleetbase.api') // Only the socket server calls this; its signature is the whole of its authentication. ->and($authorize['action'])->toBe($controller . '@authorizeChannel') ->and($authorize['middleware'])->toBe([Fleetbase\Http\Middleware\VerifySocketSignature::class]); From 4ff2c6f8255b2a131cce9e4fb58b11930a79ba85 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 15:11:44 +0800 Subject: [PATCH 11/51] test(socket-auth): fix the missing-schema fixture and report every channel decision The install-channel test skipped the users table but still seeded it. The cross-company and driver tests now compare every channel's decision reason (and any resolver error logged) in one assertion. --- .../SocketChannelAuthorizationTest.php | 56 +++++++++++++------ 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/tests/Unit/Support/SocketChannelAuthorizationTest.php b/tests/Unit/Support/SocketChannelAuthorizationTest.php index b539ddc8..83d796f0 100644 --- a/tests/Unit/Support/SocketChannelAuthorizationTest.php +++ b/tests/Unit/Support/SocketChannelAuthorizationTest.php @@ -60,6 +60,20 @@ function socket_channel_core_authorizer(): ChannelAuthorizer return new ChannelAuthorizer($registry); } +/** + * The decision reason for each channel, keyed by channel. + */ +function socket_channel_reasons(ChannelAuthorizer $authorizer, SocketPrincipal $principal, array $channels): array +{ + $reasons = []; + + foreach ($channels as $channel) { + $reasons[$channel] = $authorizer->authorize($principal, $channel)->reason; + } + + return $reasons; +} + afterEach(function () { SocketAuthFixtures::reset(); }); @@ -77,7 +91,7 @@ function socket_channel_core_authorizer(): ChannelAuthorizer ]); test('anonymous connections may follow the install channel only until setup creates a user', function () { - SocketAuthFixtures::database(SocketAuthFixtures::KEY, ['users']); + SocketAuthFixtures::database(SocketAuthFixtures::KEY, ['users'], false); $missingSchema = (new ChannelAuthorizer(new SocketChannelRegistry()))->authorize(null, 'fleetbase.install'); SocketAuthFixtures::database(SocketAuthFixtures::KEY, [], false); @@ -341,15 +355,19 @@ function socket_channel_core_authorizer(): ChannelAuthorizer $authorizer = socket_channel_core_authorizer(); $user = socket_channel_principal(); $api = socket_channel_principal(['kind' => 'api', 'sub' => 'cred-a', 'ids' => ['cred-a']]); - - foreach (['chat.chat_aaa', 'chat_channel.chat-a', 'chat_participant.participant-a1', 'chat_message.chat_message_a', 'file.file_aaa', 'user.user-a2', 'api.cred-a', 'test.user-a1'] as $channel) { - expect($authorizer->authorize($user, $channel)->allow)->toBeTrue(); - } - - foreach (['chat.chat_bbb', 'chat_channel.chat-b', 'chat_participant.participant-b1', 'chat_message.chat_message_b', 'file.file_bbb', 'user.user_b1', 'company.company_bbb', 'api.cred-b', 'api.2', 'test.user-b1', 'install.company-b.fleetops'] as $channel) { - expect($authorizer->authorize($user, $channel)->toArray())->toBe(['allow' => false, 'ttl' => 30, 'reason' => 'forbidden']) - ->and($authorizer->authorize($api, $channel)->allow)->toBeFalse(); - } + $allowed = ['chat.chat_aaa', 'chat_channel.chat-a', 'chat_participant.participant-a1', 'chat_message.chat_message_a', 'file.file_aaa', 'user.user-a2', 'api.cred-a', 'test.user-a1']; + $denied = ['chat.chat_bbb', 'chat_channel.chat-b', 'chat_participant.participant-b1', 'chat_message.chat_message_b', 'file.file_bbb', 'user.user_b1', 'company.company_bbb', 'api.cred-b', 'api.2', 'test.user-b1', 'install.company-b.fleetops']; + + // Collected rather than asserted one by one so a failure shows every reason and any resolver error. + expect([ + 'user' => socket_channel_reasons($authorizer, $user, array_merge($allowed, $denied)), + 'api' => socket_channel_reasons($authorizer, $api, $denied), + 'log' => app('log')->entries, + ])->toBe([ + 'user' => array_merge(array_fill_keys($allowed, 'resolver'), array_fill_keys($denied, 'forbidden')), + 'api' => array_fill_keys($denied, 'forbidden'), + 'log' => [], + ]); }); test('drivers reach only the chats they take part in', function () { @@ -357,14 +375,16 @@ function socket_channel_core_authorizer(): ChannelAuthorizer $authorizer = socket_channel_core_authorizer(); $driver = socket_channel_driver(); - - foreach (['chat.chat_aaa', 'chat_channel.chat-a', 'chat_participant.chat_participant_a1', 'chat_message.message-a', 'user.user-a1', 'driver.driver-1'] as $channel) { - expect($authorizer->authorize($driver, $channel)->allow)->toBeTrue(); - } - - foreach (['chat.chat_bbb', 'chat_participant.participant-b1', 'chat_message.message-b', 'user.user-a2', 'company.company-a', 'file.file_aaa', 'api.cred-a'] as $channel) { - expect($authorizer->authorize($driver, $channel)->allow)->toBeFalse(); - } + $allowed = ['chat.chat_aaa', 'chat_channel.chat-a', 'chat_participant.chat_participant_a1', 'chat_message.message-a']; + $denied = ['chat.chat_bbb', 'chat_participant.participant-b1', 'chat_message.message-b', 'user.user-a2', 'company.company-a', 'file.file_aaa', 'api.cred-a']; + + expect([ + 'driver' => socket_channel_reasons($authorizer, $driver, array_merge($allowed, ['user.user-a1', 'driver.driver-1'], $denied)), + 'log' => app('log')->entries, + ])->toBe([ + 'driver' => array_merge(array_fill_keys($allowed, 'resolver'), ['user.user-a1' => 'self', 'driver.driver-1' => 'self'], array_fill_keys($denied, 'forbidden')), + 'log' => [], + ]); expect($authorizer->authorize(socket_channel_driver(['ids' => ['driver-1']]), 'chat.chat_aaa')->allow)->toBeFalse() ->and($authorizer->authorize(socket_channel_driver(['ids' => []]), 'chat.chat_aaa')->allow)->toBeFalse() From 232704568e607862cebdaeb50fdfee47497185a4 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 15:15:08 +0800 Subject: [PATCH 12/51] fix(socket-auth): skip default eager loads when resolving a channel's model ChatMessage always eager loads its attachments, so authorizing a chat_message channel queried chat_attachments for nothing (and failed where that table is absent). Channel lookups now load only the model's own row. --- src/Support/SocketCluster/ModelChannelResolver.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Support/SocketCluster/ModelChannelResolver.php b/src/Support/SocketCluster/ModelChannelResolver.php index 583df828..66279ce5 100644 --- a/src/Support/SocketCluster/ModelChannelResolver.php +++ b/src/Support/SocketCluster/ModelChannelResolver.php @@ -44,10 +44,13 @@ public function authorize(SocketPrincipal $principal, string $id, string $channe /** * Find a model by uuid or public_id in the principal's environment (sandbox for test). + * + * Relations a model always eager loads are skipped: authorization only reads its own columns. */ public static function find(string $modelClass, string $id, SocketPrincipal $principal): ?object { return $modelClass::on(static::connection($principal)) + ->setEagerLoads([]) ->where(function ($query) use ($id) { $query->where('uuid', $id)->orWhere('public_id', $id); }) From 1e9e0c7a3f41b3e5db8cc867e66f5d83e8654774 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 16:43:50 +0800 Subject: [PATCH 13/51] perf: cache the country lookup used by currency and name helpers getCountryCodeByCurrency() and getCountryCodeByName() rebuilt the full countries dataset (a 4.7 MB JSON file plus flag hydration) on every call. Storefront serializes a country per store, so listing stores paid that cost once per record and network store lists could take minutes. The name/ISO2/currency rows are now built once, kept in the application cache and memoized per process. Cache failures fall back to building the lookup, and flushCountryLookup() resets it. --- src/Support/Utils.php | 100 ++++++++++++++++++++++++------- tests/Unit/Support/UtilsTest.php | 62 +++++++++++++++++++ 2 files changed, 139 insertions(+), 23 deletions(-) diff --git a/src/Support/Utils.php b/src/Support/Utils.php index a26b916f..d548060f 100644 --- a/src/Support/Utils.php +++ b/src/Support/Utils.php @@ -1129,28 +1129,94 @@ public static function isNotScalar($target) } /** - * Returns the ISO2 country name by providing a countries full name. + * Cache key for the country name, ISO2 and currency lookup. + */ + public const COUNTRY_LOOKUP_CACHE_KEY = 'fleetbase:utils:country-lookup:v1'; + + /** + * Country lookup memoized for the current process. * - * @param string countryName + * @var array|null */ - public static function getCountryCodeByName(?string $countryName, ?string $defaultValue = null): ?string + protected static ?array $countryLookup = null; + + /** + * Name, ISO2 and primary currency for every country. + * + * Building this list loads and hydrates the full countries dataset, which costs + * between a fraction of a second and several seconds. It is built once, kept in + * the application cache and memoized per process, so per-record callers (for + * example a store resource resolving its country from its currency) stay cheap. + * + * @return array + */ + public static function getCountryLookup(): array { - if (static::isEmpty($countryName) || !is_string($countryName)) { - return $defaultValue; + if (static::$countryLookup !== null) { + return static::$countryLookup; } - $countries = new \PragmaRX\Countries\Package\Countries(); - $countries = $countries + try { + $cached = Cache::get(static::COUNTRY_LOOKUP_CACHE_KEY); + } catch (\Throwable $e) { + $cached = null; + } + + if (is_array($cached) && !empty($cached)) { + return static::$countryLookup = $cached; + } + + $lookup = (new \PragmaRX\Countries\Package\Countries()) ->all() ->map(function ($country) { return [ - 'name' => static::get($country, 'name.common'), - 'iso2' => static::get($country, 'cca2'), + 'name' => static::get($country, 'name.common'), + 'iso2' => static::get($country, 'cca2'), + 'currency' => static::resolveCurrencyCode(static::get($country, 'currencies', [])), ]; }) ->values() ->toArray(); - $countries = collect($countries); + + try { + Cache::put(static::COUNTRY_LOOKUP_CACHE_KEY, $lookup); + } catch (\Throwable $e) { + // The lookup still works without a cache; it is rebuilt once per process. + } + + return static::$countryLookup = $lookup; + } + + /** + * Forget the memoized country lookup, and optionally the cached copy. + */ + public static function flushCountryLookup(bool $forgetCache = true): void + { + static::$countryLookup = null; + + if (!$forgetCache) { + return; + } + + try { + Cache::forget(static::COUNTRY_LOOKUP_CACHE_KEY); + } catch (\Throwable $e) { + // Nothing cached to forget. + } + } + + /** + * Returns the ISO2 country name by providing a countries full name. + * + * @param string countryName + */ + public static function getCountryCodeByName(?string $countryName, ?string $defaultValue = null): ?string + { + if (static::isEmpty($countryName) || !is_string($countryName)) { + return $defaultValue; + } + + $countries = collect(static::getCountryLookup()); $data = $countries->first(function ($country) use ($countryName) { // @todo switch to string contains or like search @@ -1174,19 +1240,7 @@ public static function getCountryCodeByCurrency(?string $currencyCode, ?string $ return $defaultValue; } - $countries = new \PragmaRX\Countries\Package\Countries(); - $countries = $countries - ->all() - ->map(function ($country) { - return [ - 'name' => static::get($country, 'name.common'), - 'iso2' => static::get($country, 'cca2'), - 'currency' => static::resolveCurrencyCode(static::get($country, 'currencies', [])), - ]; - }) - ->values() - ->toArray(); - $countries = collect($countries); + $countries = collect(static::getCountryLookup()); $data = $countries->first(function ($country) use ($currencyCode) { return is_string($country['currency']) && strtolower($country['currency']) === strtolower($currencyCode); diff --git a/tests/Unit/Support/UtilsTest.php b/tests/Unit/Support/UtilsTest.php index d042ad81..faf29e8c 100644 --- a/tests/Unit/Support/UtilsTest.php +++ b/tests/Unit/Support/UtilsTest.php @@ -1234,3 +1234,65 @@ public function toArray($request): array Utils::deleteDirectory($noPsrRoot); } }); + +test('utils builds the country lookup once and serves it from cache', function () { + bind_test_container(); + Utils::flushCountryLookup(); + + // A cached lookup is used as is, without loading the countries dataset. + app('cache')->put(Utils::COUNTRY_LOOKUP_CACHE_KEY, [ + ['name' => 'Testland', 'iso2' => 'TL', 'currency' => 'TLD'], + ]); + + expect(Utils::getCountryCodeByCurrency('TLD'))->toBe('TL') + ->and(Utils::getCountryCodeByName('testland'))->toBe('TL') + ->and(Utils::getCountryCodeByCurrency('MNT', 'ZZ'))->toBe('ZZ'); + + // Memoized per process: the cache is not read again. + app('cache')->forget(Utils::COUNTRY_LOOKUP_CACHE_KEY); + expect(Utils::getCountryCodeByCurrency('TLD'))->toBe('TL'); + + // Flushing rebuilds from the dataset and caches the result. + Utils::flushCountryLookup(); + expect(Utils::getCountryCodeByCurrency('MNT'))->toBe('MN') + ->and(Utils::getCountryCodeByCurrency('TLD'))->toBeNull(); + + $cached = app('cache')->get(Utils::COUNTRY_LOOKUP_CACHE_KEY); + expect($cached)->toBeArray() + ->and(collect($cached)->firstWhere('iso2', 'SG'))->toMatchArray(['iso2' => 'SG', 'currency' => 'SGD']); + + // Flushing only the memo keeps the cached copy. + Utils::flushCountryLookup(false); + expect(app('cache')->get(Utils::COUNTRY_LOOKUP_CACHE_KEY))->toBe($cached); + + Utils::flushCountryLookup(); +}); + +test('utils country lookup works when the cache store is unavailable', function () { + $container = bind_test_container(); + $container->instance('cache', new class { + public function get(string $key, mixed $default = null): mixed + { + throw new RuntimeException('cache down'); + } + + public function put(string $key, mixed $value, mixed $ttl = null): bool + { + throw new RuntimeException('cache down'); + } + + public function forget(string $key): bool + { + throw new RuntimeException('cache down'); + } + }); + Illuminate\Support\Facades\Cache::clearResolvedInstances(); + Utils::flushCountryLookup(); + + expect(Utils::getCountryCodeByCurrency('SGD'))->toBe('SG') + ->and(Utils::getCountryCodeByName('Mongolia'))->toBe('MN'); + + Utils::flushCountryLookup(); + Illuminate\Support\Facades\Cache::clearResolvedInstances(); + bind_test_container(); +}); From d569a2234e44cea4862f02d182a5322fcceedd5c Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 16:57:57 +0800 Subject: [PATCH 14/51] perf: index files.subject_uuid Store media, product images and proofs of delivery are looked up by subject_uuid, which had no index, so each lookup scanned the whole files table. --- ..._add_subject_uuid_index_to_files_table.php | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 migrations/2026_10_06_000000_add_subject_uuid_index_to_files_table.php diff --git a/migrations/2026_10_06_000000_add_subject_uuid_index_to_files_table.php b/migrations/2026_10_06_000000_add_subject_uuid_index_to_files_table.php new file mode 100644 index 00000000..31d02e84 --- /dev/null +++ b/migrations/2026_10_06_000000_add_subject_uuid_index_to_files_table.php @@ -0,0 +1,46 @@ +indexExists('files', 'files_subject_uuid_index')) { + return; + } + + Schema::table('files', function (Blueprint $table) { + $table->index('subject_uuid'); + }); + } + + public function down(): void + { + if (!$this->indexExists('files', 'files_subject_uuid_index')) { + return; + } + + Schema::table('files', function (Blueprint $table) { + $table->dropIndex(['subject_uuid']); + }); + } + + protected function indexExists(string $table, string $index): bool + { + try { + $indexes = Schema::getConnection() + ->getDoctrineSchemaManager() + ->listTableIndexes($table); + + return isset($indexes[$index]); + } catch (Throwable $e) { + return false; + } + } +}; From a580cde15bdff3f19b67d13d1e667418815baa7b Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 7 Oct 2026 13:50:36 +0800 Subject: [PATCH 15/51] chore(release): v1.6.69 --- RELEASE.md | 27 +++++++++++++-------------- composer.json | 2 +- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 149eb425..f3700ef1 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,26 +1,25 @@ -# v1.6.68 — Resource transformers apply to every resource +# v1.6.69 — Authenticated realtime channels, private media, database backups and hashed codes ## Added -- **Agnostic resource transformers.** Any extension can decorate the serialized output of any API resource without modifying the resource or its model. Register a transformer against an HTTP resource class, an Eloquent model class, an interface, or `'*'` (subclasses match), and `FleetbaseResource::resolve()` applies it to JSON responses, nested resources, collection items, webhook payloads and broadcast payloads. Transformers chain in ascending `priority` and can be scoped by `contexts` (`http`, `webhook`, `broadcast`) and `only` (`internal`, `public`). (#285) -- `Fleetbase\Contracts\ResourceTransformer`, `Fleetbase\Contracts\PreparesResourceTransformation` (a once-per-collection `prepare()` hook for batch loading, so transformers never add N+1 queries), `Fleetbase\Support\ResourceTransformerContext`, and the `Fleetbase\Http\Transformers\Transformer` base class. -- Closure transformers via `ResourceTransformerRegistry::register(fn (...) => ..., ['target' => ...])`. -- `CoreServiceProvider::$transformers`, `registerTransformers()` and `registerTransformersFrom(__DIR__ . '/../Http/Transformers')` for declarative and directory-based registration from extensions, mirroring expansions. +- **Authenticated realtime channels.** Socket tokens, a channel authorizer with per-resource resolvers, and signed HTTP publish. `POST int/v1/socket/token` mints a user token, and `POST int/v1/socket/authorize` is called only by the socket server, with signed requests. Extensions register channel resolvers for their own resources. It is off by default; roll it out with the socket server's `off`, `log` and `enforce` modes. (#290) +- **Database backups.** Settings-driven backups (`db:backup`) on a configurable schedule, with environment defaults in `config/database-backups.php` (`DB_BACKUP_*`) and an admin override. Failures email the configured addresses and are logged. (#288) +- **Hashed one-time codes.** `VerificationCode::issue()` stores an HMAC of the code and returns the plain code once. `check()` counts attempts and reports `valid`, `invalid`, `expired` or `locked`. The existing generators are unchanged. (#289) ## Changed -- `FleetbaseResourceCollection` resolves items (instead of calling `toArray()`), sharing one `prepare()` pass per collection. A hand-built collection with a manually set `preserveKeys` now filters item arrays with the item's flag. -- `ResourceLifecycleEvent` payloads, chat participant broadcasts, `Utils::serializeJsonResource()` and the cached internal user payload serialize through `resolve()`, so transformers reach them and conditional `MissingValue`s are no longer emitted as `{}`. -- `Find::httpResourceForModel()` caches internal and public resolutions separately, consulting the request only when a model has a dedicated `Internal` resource. +- **Private media buckets.** Stored file URLs that point into the configured `s3` bucket are signed again on read, so the bucket can be fully private. (#287) +- **Faster lookups.** The country lookup is cached, and `files.subject_uuid` is indexed. (#291) +- The admin SocketCluster test always publishes to `test.{current user uuid}` and returns the channel it used. (#290) ## Removed -- Legacy duck-typed transformers (`$target` property + static `output($model, $data)`), `ResourceTransformerRegistry::transform(Model, array)`, `resolveByTarget()`, `fixClassName()` and the static `$transformers` array. The `User` resource no longer calls the registry directly. - -## Dependencies - -- `fleetbase/laravel-mysql-spatial` `^1.0.3`. The spatial `MysqlConnection` no longer connects to MySQL when the connection object is built, so resolving `DB::connection()` during boot (for example `artisan package:discover` during `composer install`) no longer requires a reachable database. +- `MysqlS3Backup`, `S3BackupTrimmer` and `config/laravel-mysql-s3-backup.php`, replaced by the new database backups. (#288) ## Upgrade Steps -- Extensions that registered a legacy transformer must implement `Fleetbase\Contracts\ResourceTransformer` (or extend `Fleetbase\Http\Transformers\Transformer`) and register it through `$transformers` or `registerTransformersFrom()`. See the README section "Resource transformers". The only known legacy consumer, aws-marketplace, is deprecated and is not updated. +- Run migrations: `database_backups` table (#288) and the `files.subject_uuid` index (#291). +- Socket auth (#290) adds `SOCKETCLUSTER_AUTH_KEY`, `SOCKETCLUSTER_PUBLISH_URL` (default `http://{SOCKETCLUSTER_HOST}:8001`) and `SOCKETCLUSTER_TOKEN_TTL` (default 900) to `broadcasting.connections.socketcluster`. Nothing changes until the socket server enforces it. +- To make the media bucket private, remove any public `s3:GetObject` statement from the bucket policy and turn on Block Public Access (#287). +- Database backups replace the old S3 backup settings; configure them with `DB_BACKUP_*` or in the admin settings (#288). +- fleetbase/storefront v0.4.25 and fleetbase/fleetops#358 require this release (`fleetbase/core-api ^1.6.69`). diff --git a/composer.json b/composer.json index 8aa81946..d1570eca 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/core-api", - "version": "1.6.68", + "version": "1.6.69", "description": "Core Framework and Resources for Fleetbase API", "keywords": [ "fleetbase", From 11114518b39d2b4ac8a4f26eb127b505a7e70350 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 7 Oct 2026 14:16:52 +0800 Subject: [PATCH 16/51] feat(socket-auth): SOCKETCLUSTER_AUTH_ENABLED switch, off by default Socket authentication was on as soon as SOCKETCLUSTER_AUTH_KEY was set. That also moved every broadcast to the socket server's HTTP publish endpoint, so provisioning the key before every client fetched tokens (or before the new socket server was deployed) would break existing socket clients. - New `broadcasting.connections.socketcluster.auth_enabled` (SOCKETCLUSTER_AUTH_ENABLED, default false). SocketToken::enabled() now needs the switch and a valid key. Every gated path follows: token routes, the authorize endpoint and its signature check, and HTTP publishing. - With the switch off, the console's socket test publishes to the requested channel (default `test`) as before. With it on, only to `test.{user}`. - README: the switch and the rollout order (ship clients that fall back on 404, switch on with the socket server in log mode, then enforce). --- README.md | 8 ++- config/broadcasting.connections.php | 7 +- .../Internal/v1/SettingController.php | 11 ++-- src/Support/SocketCluster/SocketToken.php | 18 ++++- tests/Fixtures/Support/SocketAuthFixtures.php | 9 +-- .../SettingControllerExternalProbesTest.php | 65 +++++++++++++++---- tests/Unit/Support/SocketTokenTest.php | 33 +++++++++- 7 files changed, 122 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index de43866d..bc1496b2 100644 --- a/README.md +++ b/README.md @@ -112,10 +112,16 @@ Notes: ## Realtime channel authentication -Setting `SOCKETCLUSTER_AUTH_KEY` (a shared secret of at least 32 characters, also given to the socket server) turns on authenticated realtime channels. Without it nothing changes: no socket tokens are minted, the token routes answer 404, and broadcasts use the websocket publisher as before. +Authenticated realtime channels are on only when `SOCKETCLUSTER_AUTH_ENABLED=true` **and** `SOCKETCLUSTER_AUTH_KEY` is set (a shared secret of at least 32 characters, also given to the socket server). Until then nothing changes: no socket tokens are minted, the token routes answer 404, broadcasts use the websocket publisher as before, and the console's socket test publishes to the channel it asks for. + +The switch is separate from the key so a deployment can provision the key ahead of time and keep every existing socket client working (mobile apps, the console, integrations) until they all fetch socket tokens. Roll out in this order: +1. Ship clients that request a socket token and fall back to connecting without one when the token route answers 404. +2. Set `SOCKETCLUSTER_AUTH_ENABLED=true` on the API, queue and scheduler, and run the socket server with `SOCKETCLUSTER_AUTH_MODE=log`. +3. Check the socket server's deny log, then switch it to `enforce`. | Variable | Default | Meaning | |---|---|---| +| `SOCKETCLUSTER_AUTH_ENABLED` | `false` | Turns authenticated realtime channels on. Has no effect without `SOCKETCLUSTER_AUTH_KEY`. | | `SOCKETCLUSTER_AUTH_KEY` | unset | Signs socket tokens (HS256) and, through derived keys, the API to socket server requests. | | `SOCKETCLUSTER_PUBLISH_URL` | `http://{SOCKETCLUSTER_HOST}:8001` | The socket server's internal listener; broadcasts are sent as one signed `POST {url}/publish`. | | `SOCKETCLUSTER_TOKEN_TTL` | `900` | Lifetime in seconds of user, API, driver, customer and checkout tokens. | diff --git a/config/broadcasting.connections.php b/config/broadcasting.connections.php index 214514e3..65f0a568 100644 --- a/config/broadcasting.connections.php +++ b/config/broadcasting.connections.php @@ -24,8 +24,11 @@ 'query' => [], ], - // Realtime channel authentication. Leaving SOCKETCLUSTER_AUTH_KEY unset keeps the - // feature off: no socket tokens are minted and broadcasts use the websocket publisher. + // Realtime channel authentication. It is off unless SOCKETCLUSTER_AUTH_ENABLED is true + // and SOCKETCLUSTER_AUTH_KEY is set: until then no socket tokens are minted and + // broadcasts use the websocket publisher, so existing socket clients keep working. + // Turn it on once every client fetches socket tokens. + 'auth_enabled' => Utils::castBoolean(env('SOCKETCLUSTER_AUTH_ENABLED', false)), 'auth_key' => env('SOCKETCLUSTER_AUTH_KEY'), 'publish_url' => env('SOCKETCLUSTER_PUBLISH_URL', 'http://' . env('SOCKETCLUSTER_HOST', 'socket') . ':8001'), 'token_ttl' => (int) env('SOCKETCLUSTER_TOKEN_TTL', 900), diff --git a/src/Http/Controllers/Internal/v1/SettingController.php b/src/Http/Controllers/Internal/v1/SettingController.php index df5a00e4..be04ac17 100644 --- a/src/Http/Controllers/Internal/v1/SettingController.php +++ b/src/Http/Controllers/Internal/v1/SettingController.php @@ -1040,8 +1040,10 @@ public function testSentryConfig(AdminRequest $request) /** * Test SocketCluster Configuration. * - * Publishes only to the signed-in user's own `test.{user uuid}` channel; any channel in the - * request is ignored. The channel used is returned so the console can subscribe to it. + * With socket authentication on, publishes only to the signed-in user's own + * `test.{user uuid}` channel and ignores any channel in the request. With it off, publishes + * to the requested channel (default `test`) as before, so existing consoles keep working. + * The channel used is returned so the console can subscribe to it. * * @param Request $request the incoming HTTP request containing the authenticated user * @@ -1050,8 +1052,9 @@ public function testSentryConfig(AdminRequest $request) public function testSocketcluster(AdminRequest $request) { $userUuid = session('user'); + $scoped = \Fleetbase\Support\SocketCluster\SocketToken::enabled(); - if (!is_string($userUuid) || $userUuid === '') { + if ($scoped && (!is_string($userUuid) || $userUuid === '')) { return response()->json([ 'status' => 'error', 'message' => 'No signed-in user to publish the test message for.', @@ -1061,7 +1064,7 @@ public function testSocketcluster(AdminRequest $request) } // Get the channel to publish to - $channel = 'test.' . $userUuid; + $channel = $scoped ? 'test.' . $userUuid : (string) $request->input('channel', 'test'); $message = 'Socket broadcasted message successfully.'; $status = 'success'; $sent = false; diff --git a/src/Support/SocketCluster/SocketToken.php b/src/Support/SocketCluster/SocketToken.php index 57ae85b6..c9115907 100644 --- a/src/Support/SocketCluster/SocketToken.php +++ b/src/Support/SocketCluster/SocketToken.php @@ -18,8 +18,9 @@ /** * Mints and verifies the HS256 tokens realtime clients present to the socket server. * - * The signing key is SOCKETCLUSTER_AUTH_KEY, shared with the socket server. Without it - * (or with one shorter than 32 bytes) the feature is off and nothing is minted. + * The signing key is SOCKETCLUSTER_AUTH_KEY, shared with the socket server. The feature is + * on only when SOCKETCLUSTER_AUTH_ENABLED is true and that key is at least 32 bytes long; + * otherwise nothing is minted and broadcasts use the websocket publisher. */ class SocketToken { @@ -54,9 +55,20 @@ public static function key(): ?string return is_string($key) && strlen($key) >= self::MIN_KEY_LENGTH ? $key : null; } + /** + * Whether realtime channel authentication is switched on. + * + * The switch is separate from the key so the key can be provisioned (for example on + * the socket server) before every socket client is ready for tokens. + */ + public static function switchedOn(): bool + { + return filter_var(config('broadcasting.connections.socketcluster.auth_enabled', false), FILTER_VALIDATE_BOOLEAN); + } + public static function enabled(): bool { - return static::key() !== null; + return static::switchedOn() && static::key() !== null; } /** diff --git a/tests/Fixtures/Support/SocketAuthFixtures.php b/tests/Fixtures/Support/SocketAuthFixtures.php index fb09a309..22cf4f10 100644 --- a/tests/Fixtures/Support/SocketAuthFixtures.php +++ b/tests/Fixtures/Support/SocketAuthFixtures.php @@ -35,10 +35,11 @@ class SocketAuthFixtures public static function container(?string $key = self::KEY, array $config = []): Container { $container = bind_test_container(array_merge([ - 'broadcasting.connections.socketcluster.auth_key' => $key, - 'broadcasting.connections.socketcluster.publish_url' => 'http://socket.test:8001', - 'broadcasting.connections.socketcluster.token_ttl' => 900, - 'broadcasting.connections.socketcluster.options' => [ + 'broadcasting.connections.socketcluster.auth_enabled' => $key !== null, + 'broadcasting.connections.socketcluster.auth_key' => $key, + 'broadcasting.connections.socketcluster.publish_url' => 'http://socket.test:8001', + 'broadcasting.connections.socketcluster.token_ttl' => 900, + 'broadcasting.connections.socketcluster.options' => [ 'secure' => false, 'host' => 'socket.test', 'port' => 8000, diff --git a/tests/Unit/Http/SettingControllerExternalProbesTest.php b/tests/Unit/Http/SettingControllerExternalProbesTest.php index f3948b38..dd7538d3 100644 --- a/tests/Unit/Http/SettingControllerExternalProbesTest.php +++ b/tests/Unit/Http/SettingControllerExternalProbesTest.php @@ -169,41 +169,82 @@ function setting_controller_external_probe_request(array $input = []): AdminRequ ->and($twilio->messages)->toBe([]); }); -test('test socketcluster returns stable json when the configured socket cannot send', function () { +test('test socketcluster publishes to the requested channel while socket authentication is off', function () { setting_controller_external_probe_fixtures(); - session(['user' => 'user-probe']); + session()->flush(); - // Any requested channel is ignored: the probe only ever publishes to the admin's own test channel. + // Existing consoles pick their own test channel; that keeps working until auth is switched on. $response = (new SettingController())->testSocketcluster(setting_controller_external_probe_request([ - 'channel' => 'company.someone-else', + 'channel' => 'settings-probe', ])); - session()->flush(); - expect($response->getStatusCode())->toBe(200) ->and($response->getData(true))->toBe([ 'status' => 'error', 'message' => 'Socket broadcasted message successfully.', - 'channel' => 'test.user-probe', + 'channel' => 'settings-probe', 'response' => null, ]); + + $default = (new SettingController())->testSocketcluster(setting_controller_external_probe_request()); + + expect($default->getData(true)['channel'])->toBe('test'); }); -test('test socketcluster refuses to publish without a signed-in user', function () { - setting_controller_external_probe_fixtures(); +test('test socketcluster ignores the key while the auth switch is off', function () { + setting_controller_external_probe_fixtures([ + 'broadcasting.connections.socketcluster.auth_enabled' => false, + 'broadcasting.connections.socketcluster.auth_key' => str_repeat('k', 40), + ]); session()->flush(); + $response = (new SettingController())->testSocketcluster(setting_controller_external_probe_request([ + 'channel' => 'settings-probe', + ])); + + expect($response->getData(true)['channel'])->toBe('settings-probe'); +}); + +test('test socketcluster only publishes to the admin test channel while socket authentication is on', function () { + setting_controller_external_probe_fixtures([ + 'broadcasting.connections.socketcluster.auth_enabled' => true, + 'broadcasting.connections.socketcluster.auth_key' => str_repeat('k', 40), + 'broadcasting.connections.socketcluster.publish_url' => 'http://socket.test:8001', + ]); + app()->instance(Illuminate\Http\Client\Factory::class, new Illuminate\Http\Client\Factory()); + Facade::clearResolvedInstances(); + Illuminate\Support\Facades\Http::fake(['*' => Illuminate\Support\Facades\Http::response('unavailable', 503)]); + session(['user' => 'user-probe']); + + // Any requested channel is ignored: the probe only ever publishes to the admin's own test channel. $response = (new SettingController())->testSocketcluster(setting_controller_external_probe_request([ 'channel' => 'company.someone-else', ])); + session()->flush(); + expect($response->getStatusCode())->toBe(200) ->and($response->getData(true))->toBe([ 'status' => 'error', - 'message' => 'No signed-in user to publish the test message for.', - 'channel' => null, - 'response' => null, + 'message' => 'Socket broadcasted message successfully.', + 'channel' => 'test.user-probe', + 'response' => 'unavailable', ]); + + $refused = (new SettingController())->testSocketcluster(setting_controller_external_probe_request([ + 'channel' => 'company.someone-else', + ])); + + expect($refused->getData(true))->toBe([ + 'status' => 'error', + 'message' => 'No signed-in user to publish the test message for.', + 'channel' => null, + 'response' => null, + ]); + + // Http::fake() swaps the container's client; put a real one back for later files. + app()->instance(Illuminate\Http\Client\Factory::class, new Illuminate\Http\Client\Factory()); + Facade::clearResolvedInstances(); }); test('test sentry config rejects invalid dsns before sdk fallback handling', function () { diff --git a/tests/Unit/Support/SocketTokenTest.php b/tests/Unit/Support/SocketTokenTest.php index ceddd783..68b98032 100644 --- a/tests/Unit/Support/SocketTokenTest.php +++ b/tests/Unit/Support/SocketTokenTest.php @@ -2,6 +2,7 @@ use Fleetbase\Models\ApiCredential; use Fleetbase\Support\SocketCluster\ChannelDecision; +use Fleetbase\Support\SocketCluster\SocketClusterService; use Fleetbase\Support\SocketCluster\SocketPrincipal; use Fleetbase\Support\SocketCluster\SocketSignature; use Fleetbase\Support\SocketCluster\SocketToken; @@ -26,8 +27,8 @@ public function __construct( SocketAuthFixtures::reset(); }); -test('socket tokens are disabled without a configured key of at least 32 bytes', function () { - SocketAuthFixtures::container(null); +test('socket tokens are disabled without a configured key of at least 32 bytes, even when switched on', function () { + SocketAuthFixtures::container(null, ['broadcasting.connections.socketcluster.auth_enabled' => true]); expect(SocketToken::key())->toBeNull() ->and(SocketToken::enabled())->toBeFalse(); @@ -37,12 +38,38 @@ public function __construct( expect(SocketToken::key())->toBeNull() ->and(SocketToken::enabled())->toBeFalse(); - config(['broadcasting.connections.socketcluster.auth_key' => SocketAuthFixtures::KEY]); + config(['broadcasting.connections.socketcluster.auth_enabled' => true, 'broadcasting.connections.socketcluster.auth_key' => SocketAuthFixtures::KEY]); expect(SocketToken::key())->toBe(SocketAuthFixtures::KEY) ->and(SocketToken::enabled())->toBeTrue(); }); +test('socket tokens stay off until the auth switch is on, even with a valid key', function () { + SocketAuthFixtures::container(SocketAuthFixtures::KEY, ['broadcasting.connections.socketcluster.auth_enabled' => false]); + + expect(SocketToken::key())->toBe(SocketAuthFixtures::KEY) + ->and(SocketToken::switchedOn())->toBeFalse() + ->and(SocketToken::enabled())->toBeFalse() + ->and(SocketClusterService::publishesOverHttp())->toBeFalse(); + + // Values read from the environment arrive as strings. + config(['broadcasting.connections.socketcluster.auth_enabled' => 'true']); + + expect(SocketToken::switchedOn())->toBeTrue() + ->and(SocketToken::enabled())->toBeTrue() + ->and(SocketClusterService::publishesOverHttp())->toBeTrue(); + + config(['broadcasting.connections.socketcluster.auth_enabled' => 'false']); + + expect(SocketToken::enabled())->toBeFalse(); + + // The switch alone is not enough without a key. + config(['broadcasting.connections.socketcluster.auth_enabled' => true, 'broadcasting.connections.socketcluster.auth_key' => null]); + + expect(SocketToken::switchedOn())->toBeTrue() + ->and(SocketToken::enabled())->toBeFalse(); +}); + test('issuing a socket token requires the feature to be configured', function () { SocketAuthFixtures::container(null); From b02d9487ba16acee49197f5e905d74ee60003f82 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 7 Oct 2026 14:23:33 +0800 Subject: [PATCH 17/51] fix(backups): queue the manual backup through the bus dispatcher RunDatabaseBackup::dispatch() comes from the Dispatchable trait. Several test files define an empty Illuminate\Foundation\Bus\Dispatchable shim, so when one of them loads first the static dispatch() is missing and DatabaseBackupsTest fails depending on test order (PHP CI on release/v1.6.69 fails this way). Dispatching through the bus contract behaves the same at runtime and does not depend on the trait. --- src/Http/Controllers/Internal/v1/DatabaseBackupController.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Http/Controllers/Internal/v1/DatabaseBackupController.php b/src/Http/Controllers/Internal/v1/DatabaseBackupController.php index 2784d49a..4cf5bf68 100644 --- a/src/Http/Controllers/Internal/v1/DatabaseBackupController.php +++ b/src/Http/Controllers/Internal/v1/DatabaseBackupController.php @@ -7,6 +7,7 @@ use Fleetbase\Jobs\RunDatabaseBackup; use Fleetbase\Models\DatabaseBackup; use Fleetbase\Support\DatabaseBackupSettings; +use Illuminate\Contracts\Bus\Dispatcher; use Illuminate\Http\JsonResponse; use Illuminate\Validation\Rule; @@ -76,7 +77,7 @@ public function runs(AdminRequest $request): JsonResponse */ public function run(AdminRequest $request): JsonResponse { - RunDatabaseBackup::dispatch(DatabaseBackup::TRIGGER_MANUAL); + app(Dispatcher::class)->dispatch(new RunDatabaseBackup(DatabaseBackup::TRIGGER_MANUAL)); return response()->json(['status' => 'queued'], 202); } From 0093a1cc034f9a650f6f4a08b220cf97f590b81d Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 7 Oct 2026 14:26:16 +0800 Subject: [PATCH 18/51] docs(release): socket auth stays off until SOCKETCLUSTER_AUTH_ENABLED=true --- RELEASE.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index f3700ef1..92381f23 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -2,7 +2,7 @@ ## Added -- **Authenticated realtime channels.** Socket tokens, a channel authorizer with per-resource resolvers, and signed HTTP publish. `POST int/v1/socket/token` mints a user token, and `POST int/v1/socket/authorize` is called only by the socket server, with signed requests. Extensions register channel resolvers for their own resources. It is off by default; roll it out with the socket server's `off`, `log` and `enforce` modes. (#290) +- **Authenticated realtime channels.** Socket tokens, a channel authorizer with per-resource resolvers, and signed HTTP publish. `POST int/v1/socket/token` mints a user token, and `POST int/v1/socket/authorize` is called only by the socket server, with signed requests. Extensions register channel resolvers for their own resources. It is **off by default** and stays off until `SOCKETCLUSTER_AUTH_ENABLED=true`, even with a key set, so existing socket clients (mobile apps, the console, integrations) keep working. (#290) - **Database backups.** Settings-driven backups (`db:backup`) on a configurable schedule, with environment defaults in `config/database-backups.php` (`DB_BACKUP_*`) and an admin override. Failures email the configured addresses and are logged. (#288) - **Hashed one-time codes.** `VerificationCode::issue()` stores an HMAC of the code and returns the plain code once. `check()` counts attempts and reports `valid`, `invalid`, `expired` or `locked`. The existing generators are unchanged. (#289) @@ -19,7 +19,10 @@ ## Upgrade Steps - Run migrations: `database_backups` table (#288) and the `files.subject_uuid` index (#291). -- Socket auth (#290) adds `SOCKETCLUSTER_AUTH_KEY`, `SOCKETCLUSTER_PUBLISH_URL` (default `http://{SOCKETCLUSTER_HOST}:8001`) and `SOCKETCLUSTER_TOKEN_TTL` (default 900) to `broadcasting.connections.socketcluster`. Nothing changes until the socket server enforces it. +- Socket auth (#290) adds `SOCKETCLUSTER_AUTH_ENABLED` (default `false`), `SOCKETCLUSTER_AUTH_KEY`, `SOCKETCLUSTER_PUBLISH_URL` (default `http://{SOCKETCLUSTER_HOST}:8001`) and `SOCKETCLUSTER_TOKEN_TTL` (default 900) to `broadcasting.connections.socketcluster`. Nothing changes for socket clients until `SOCKETCLUSTER_AUTH_ENABLED=true`. Roll out in this order: + 1. Ship clients that fall back to connecting without a token when the token route answers 404. + 2. Set `SOCKETCLUSTER_AUTH_ENABLED=true` on the API and the socket server, with the socket server in `log` mode. + 3. Switch the socket server to `enforce`. - To make the media bucket private, remove any public `s3:GetObject` statement from the bucket policy and turn on Block Public Access (#287). - Database backups replace the old S3 backup settings; configure them with `DB_BACKUP_*` or in the admin settings (#288). - fleetbase/storefront v0.4.25 and fleetbase/fleetops#358 require this release (`fleetbase/core-api ^1.6.69`). From f961a5d48b8aca0e05cd80836a0de12089183260 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 8 Oct 2026 10:31:22 +0800 Subject: [PATCH 19/51] fix(socket-auth): send a configurable Origin on the websocket publisher handshake The PHP publisher connected without an Origin header, which socketcluster-server treats as '*'. With origins restricted (scripts/docker-install.sh restricts them to the console host), every server broadcast was refused with 'Invalid origin: *'. SOCKETCLUSTER_ORIGIN now sets the header through phrity/websocket's headers option. Reported in fleetbase/core-api#290. --- README.md | 1 + config/broadcasting.connections.php | 5 +++++ tests/Unit/Support/SocketClusterTest.php | 12 ++++++++++++ 3 files changed, 18 insertions(+) diff --git a/README.md b/README.md index bc1496b2..dbbb70c2 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,7 @@ The switch is separate from the key so a deployment can provision the key ahead | `SOCKETCLUSTER_AUTH_KEY` | unset | Signs socket tokens (HS256) and, through derived keys, the API to socket server requests. | | `SOCKETCLUSTER_PUBLISH_URL` | `http://{SOCKETCLUSTER_HOST}:8001` | The socket server's internal listener; broadcasts are sent as one signed `POST {url}/publish`. | | `SOCKETCLUSTER_TOKEN_TTL` | `900` | Lifetime in seconds of user, API, driver, customer and checkout tokens. | +| `SOCKETCLUSTER_ORIGIN` | unset | `Origin` header the websocket publisher sends on its handshake. Set it to an origin the socket server allows (e.g. the console URL) when `SOCKETCLUSTER_OPTIONS` restricts `origins`; without it the handshake is refused as `Invalid origin: *`. Not used by the signed HTTP publish. | Clients fetch a token before connecting: `POST int/v1/socket/token` (console session), `POST v1/socket/token` (API credential or Sanctum user token). The socket server asks `POST int/v1/socket/authorize`, signed with its own derived key, whether a token may subscribe to a channel. diff --git a/config/broadcasting.connections.php b/config/broadcasting.connections.php index 65f0a568..f124df25 100644 --- a/config/broadcasting.connections.php +++ b/config/broadcasting.connections.php @@ -22,6 +22,11 @@ 'port' => env('SOCKETCLUSTER_PORT', 8000), 'path' => env('SOCKETCLUSTER_PATH', '/socketcluster/'), 'query' => [], + // The websocket publisher sends no Origin of its own, and a socket server whose + // `origins` are restricted (as scripts/docker-install.sh sets them) rejects a + // handshake without one. Set SOCKETCLUSTER_ORIGIN to an allowed origin, e.g. the + // console URL. + 'headers' => array_filter(['Origin' => env('SOCKETCLUSTER_ORIGIN')]), ], // Realtime channel authentication. It is off unless SOCKETCLUSTER_AUTH_ENABLED is true diff --git a/tests/Unit/Support/SocketClusterTest.php b/tests/Unit/Support/SocketClusterTest.php index fdfccf63..5a17abce 100644 --- a/tests/Unit/Support/SocketClusterTest.php +++ b/tests/Unit/Support/SocketClusterTest.php @@ -265,6 +265,18 @@ function decode_socket_cluster_payload(string $payload): array ->and($service->getClient())->toBeInstanceOf(Client::class); }); +it('sends configured handshake headers such as Origin to the websocket client', function () { + $service = new SocketClusterService([ + 'secure' => false, + 'host' => 'socket.test', + 'headers' => ['Origin' => 'https://console.example.test'], + ]); + + $clientOptions = (fn () => $this->options)->call($service->getClient()); + + expect($clientOptions['headers'])->toBe(['Origin' => 'https://console.example.test']); +}); + it('broadcasts payloads to every channel through the socket cluster service', function () { $service = new RecordingSocketClusterService(); $broadcaster = new SocketClusterBroadcaster($service); From 3ce18fa7306fe0b9f89b3ff4e5997fe9ac7b6a60 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 8 Oct 2026 15:26:02 +0800 Subject: [PATCH 20/51] fix(backups): no default backup bucket; use example names in tests DB_BACKUP_BUCKET now has no default, so an install writes to the bucket it configures rather than a hard-coded name. --- config/database-backups.php | 2 +- tests/Unit/DatabaseBackupsTest.php | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/config/database-backups.php b/config/database-backups.php index 6d4fd674..d46a5f6b 100644 --- a/config/database-backups.php +++ b/config/database-backups.php @@ -26,7 +26,7 @@ * s3 disks, and a key prefix. */ 'disk' => env('DB_BACKUP_DISK', 's3'), - 'bucket' => env('DB_BACKUP_BUCKET', 'fleetbase-db-backups'), + 'bucket' => env('DB_BACKUP_BUCKET'), 'path' => env('DB_BACKUP_PATH', ''), /* diff --git a/tests/Unit/DatabaseBackupsTest.php b/tests/Unit/DatabaseBackupsTest.php index 820ac9b2..1f9ead8c 100644 --- a/tests/Unit/DatabaseBackupsTest.php +++ b/tests/Unit/DatabaseBackupsTest.php @@ -242,7 +242,7 @@ function database_backups_fixture(array $config = []): array $sqlite = ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']; $disks = [ 'backups' => ['driver' => 'local', 'root' => $root . '/disk'], - 's3' => ['driver' => 's3', 'bucket' => 'fleetbase-media', 'region' => 'ap-southeast-1'], + 's3' => ['driver' => 's3', 'bucket' => 'example-media', 'region' => 'ap-southeast-1'], ]; $container = bind_test_container(array_merge([ @@ -620,12 +620,12 @@ function database_backups_command(DatabaseBackupService $service, array $input = $service->diskOverride = DatabaseBackupsDiskFake::at(database_backups_root() . '/disk'); $settings = DatabaseBackupSettings::settings(); - $service->disk(array_merge($settings, ['disk' => 's3', 'bucket' => 'fleetbase-db-backups'])); + $service->disk(array_merge($settings, ['disk' => 's3', 'bucket' => 'example-db-backups'])); $service->disk(array_merge($settings, ['disk' => 's3', 'bucket' => null])); $service->disk(array_merge($settings, ['bucket' => 'ignored'])); - expect($service->diskConfigs[0]['bucket'])->toBe('fleetbase-db-backups') - ->and($service->diskConfigs[1]['bucket'])->toBe('fleetbase-media') + expect($service->diskConfigs[0]['bucket'])->toBe('example-db-backups') + ->and($service->diskConfigs[1]['bucket'])->toBe('example-media') ->and($service->diskConfigs[2])->not->toHaveKey('bucket') ->and($service->objectPath('', 'a.sql.gz'))->toBe('a.sql.gz') ->and($service->objectPath('/x/y/', 'a.sql.gz'))->toBe('x/y/a.sql.gz'); @@ -871,7 +871,7 @@ public function lock() 'time' => '01:15', 'day_of_week' => 1, 'disk' => 's3', - 'bucket' => 'fleetbase-db-backups', + 'bucket' => 'example-db-backups', 'path' => 'mysql', 'connections' => ['primary'], 'retention_days' => 14, From 5c8c92af4f53119625a0192b69b41966708b51f1 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 8 Oct 2026 15:30:14 +0800 Subject: [PATCH 21/51] test(files): use an example bucket name in S3 key tests --- tests/Unit/Models/FileModelTest.php | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/Unit/Models/FileModelTest.php b/tests/Unit/Models/FileModelTest.php index 1dfe327f..3e416eac 100644 --- a/tests/Unit/Models/FileModelTest.php +++ b/tests/Unit/Models/FileModelTest.php @@ -345,36 +345,36 @@ function bind_file_model_filesystem(array $config = []): FileModelFilesystemFake it('extracts object keys only from urls that point into the configured s3 bucket', function () { bind_file_model_filesystem([ - 'filesystems.disks.s3.bucket' => 'fleetbase-production-media', + 'filesystems.disks.s3.bucket' => 'example-media', 'filesystems.disks.s3.url' => 'https://media.example.test/assets', ]); - expect(File::s3KeyFromUrl('https://fleetbase-production-media.s3.amazonaws.com/uploads/a/photo.png'))->toBe('uploads/a/photo.png') - ->and(File::s3KeyFromUrl('https://fleetbase-production-media.s3.ap-southeast-1.amazonaws.com/uploads/a/photo.png?X-Amz-Signature=old'))->toBe('uploads/a/photo.png') - ->and(File::s3KeyFromUrl('https://fleetbase-production-media.s3-ap-southeast-1.amazonaws.com/custom-avatars/vehicles/c/My%20Van.png'))->toBe('custom-avatars/vehicles/c/My Van.png') - ->and(File::s3KeyFromUrl('https://s3.ap-southeast-1.amazonaws.com/fleetbase-production-media/uploads/b/logo.png'))->toBe('uploads/b/logo.png') + expect(File::s3KeyFromUrl('https://example-media.s3.amazonaws.com/uploads/a/photo.png'))->toBe('uploads/a/photo.png') + ->and(File::s3KeyFromUrl('https://example-media.s3.ap-southeast-1.amazonaws.com/uploads/a/photo.png?X-Amz-Signature=old'))->toBe('uploads/a/photo.png') + ->and(File::s3KeyFromUrl('https://example-media.s3-ap-southeast-1.amazonaws.com/custom-avatars/vehicles/c/My%20Van.png'))->toBe('custom-avatars/vehicles/c/My Van.png') + ->and(File::s3KeyFromUrl('https://s3.ap-southeast-1.amazonaws.com/example-media/uploads/b/logo.png'))->toBe('uploads/b/logo.png') ->and(File::s3KeyFromUrl('https://media.example.test/assets/uploads/c/doc.pdf'))->toBe('uploads/c/doc.pdf') // other buckets, other hosts and non-urls are left alone ->and(File::s3KeyFromUrl('https://flb-assets.s3.ap-southeast-1.amazonaws.com/static/no-avatar.png'))->toBeNull() ->and(File::s3KeyFromUrl('https://s3.ap-southeast-1.amazonaws.com/flb-assets/static/no-avatar.png'))->toBeNull() - ->and(File::s3KeyFromUrl('https://evil.example.test/fleetbase-production-media.s3.amazonaws.com/x.png'))->toBeNull() - ->and(File::s3KeyFromUrl('https://fleetbase-production-media.s3.amazonaws.com.evil.test/x.png'))->toBeNull() - ->and(File::s3KeyFromUrl('https://fleetbase-production-media.s3.amazonaws.com/'))->toBeNull() + ->and(File::s3KeyFromUrl('https://evil.example.test/example-media.s3.amazonaws.com/x.png'))->toBeNull() + ->and(File::s3KeyFromUrl('https://example-media.s3.amazonaws.com.evil.test/x.png'))->toBeNull() + ->and(File::s3KeyFromUrl('https://example-media.s3.amazonaws.com/'))->toBeNull() ->and(File::s3KeyFromUrl('5f1c2a10-0000-4000-8000-000000000000'))->toBeNull() ->and(File::s3KeyFromUrl(null))->toBeNull(); // config is shared across tests: drop the url override here as well bind_file_model_filesystem(['filesystems.disks.s3.bucket' => null, 'filesystems.disks.s3.url' => null]); - expect(File::s3KeyFromUrl('https://fleetbase-production-media.s3.amazonaws.com/uploads/a/photo.png'))->toBeNull(); + expect(File::s3KeyFromUrl('https://example-media.s3.amazonaws.com/uploads/a/photo.png'))->toBeNull(); }); it('re-signs stored bucket urls and leaves every other value unchanged', function () { $filesystem = bind_file_model_filesystem([ - 'filesystems.disks.s3.bucket' => 'fleetbase-production-media', + 'filesystems.disks.s3.bucket' => 'example-media', ]); - $stored = 'https://fleetbase-production-media.s3.ap-southeast-1.amazonaws.com/custom-avatars/vehicles/c/van.png?X-Amz-Expires=7200&X-Amz-Signature=expired'; + $stored = 'https://example-media.s3.ap-southeast-1.amazonaws.com/custom-avatars/vehicles/c/van.png?X-Amz-Expires=7200&X-Amz-Signature=expired'; expect(File::signStoredUrl($stored))->toBe('https://s3.example.test/custom-avatars/vehicles/c/van.png?temporary=1') ->and($filesystem->disk('s3')->temporaryUrls)->toHaveKey('custom-avatars/vehicles/c/van.png') From ea7e5a1d45b3a859c50500a4fc707d58ec7723fe Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sun, 11 Oct 2026 13:22:52 +0800 Subject: [PATCH 22/51] fix: correct column check in deleteRecord and add ReportFilter - deleteRecord passes the bare column name to isColumn(), matching the other record lookups in HasApiModelBehavior - add ReportFilter so report listings follow the same company constraint as other company-owned resources - tests for both --- src/Http/Filter/ReportFilter.php | 16 +++++++ src/Traits/HasApiControllerBehavior.php | 2 +- .../Unit/Http/ConcreteFilterContractsTest.php | 27 +++++++++++ tests/Unit/SecurityBehavioralTest.php | 45 +++++++++++++++++++ .../Traits/HasApiControllerBehaviorTest.php | 3 +- tests/Unit/Traits/HasApiModelBehaviorTest.php | 2 + 6 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 src/Http/Filter/ReportFilter.php diff --git a/src/Http/Filter/ReportFilter.php b/src/Http/Filter/ReportFilter.php new file mode 100644 index 00000000..00a37406 --- /dev/null +++ b/src/Http/Filter/ReportFilter.php @@ -0,0 +1,16 @@ +builder->where($this->builder->qualifyColumn('company_uuid'), $this->session->get('company')); + } + + public function queryForPublic() + { + $this->queryForInternal(); + } +} diff --git a/src/Traits/HasApiControllerBehavior.php b/src/Traits/HasApiControllerBehavior.php index d3395472..1bbb7afb 100644 --- a/src/Traits/HasApiControllerBehavior.php +++ b/src/Traits/HasApiControllerBehavior.php @@ -582,7 +582,7 @@ public function deleteRecord($id, Request $request) // cross-tenant deletion (GHSA-3wj9-hh56-7fw7). No global scope backs this // up — removing it reopens that vulnerability. $companyUuid = session('company'); - if ($companyUuid && $this->model->isColumn($this->model->qualifyColumn('company_uuid'))) { + if ($companyUuid && $this->model->isColumn('company_uuid')) { $builder->where($this->model->qualifyColumn('company_uuid'), $companyUuid); } diff --git a/tests/Unit/Http/ConcreteFilterContractsTest.php b/tests/Unit/Http/ConcreteFilterContractsTest.php index 72f85db6..f69fb939 100644 --- a/tests/Unit/Http/ConcreteFilterContractsTest.php +++ b/tests/Unit/Http/ConcreteFilterContractsTest.php @@ -18,6 +18,7 @@ use Fleetbase\Http\Filter\NotificationFilter; use Fleetbase\Http\Filter\PermissionFilter; use Fleetbase\Http\Filter\PolicyFilter; +use Fleetbase\Http\Filter\ReportFilter; use Fleetbase\Http\Filter\RoleFilter; use Fleetbase\Http\Filter\ScheduleExceptionFilter; use Fleetbase\Http\Filter\ScheduleFilter; @@ -40,12 +41,14 @@ use Fleetbase\Models\Group; use Fleetbase\Models\Notification; use Fleetbase\Models\Policy; +use Fleetbase\Models\Report; use Fleetbase\Models\Role; use Fleetbase\Models\ScheduleException; use Fleetbase\Models\ScheduleItem; use Fleetbase\Models\ScheduleTemplate; use Fleetbase\Models\User; use Fleetbase\Models\WebhookEndpoint; +use Fleetbase\Support\Find; use Illuminate\Database\Capsule\Manager as Capsule; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Database\Eloquent\Model as EloquentModel; @@ -1011,6 +1014,30 @@ public function isAdmin(): bool ->and($logBuilder->queries)->toBe(['delivered']); }); +test('report filter scopes saved reports to the session company', function () { + $capsule = concrete_filter_database(); + $schema = $capsule->getConnection('mysql')->getSchemaBuilder(); + $schema->dropIfExists('reports'); + $schema->create('reports', function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('title')->nullable(); + $table->string('type')->nullable(); + $table->softDeletes(); + }); + $capsule->getConnection('mysql')->table('reports')->insert([ + ['uuid' => 'report-visible', 'public_id' => 'report_visible', 'company_uuid' => 'company-1', 'title' => 'Mine', 'type' => 'custom'], + ['uuid' => 'report-hidden', 'public_id' => 'report_hidden', 'company_uuid' => 'company-2', 'title' => 'Theirs', 'type' => 'custom'], + ['uuid' => 'report-orphan', 'public_id' => 'report_orphan', 'company_uuid' => null, 'title' => 'Orphan', 'type' => 'custom'], + ]); + + expect(Find::httpFilterForModel(new Report()))->toBe('\\Fleetbase\\Http\\Filter\\ReportFilter') + ->and(concrete_filter_uuids(ReportFilter::class, Report::class, [], 'int/v1/reports'))->toBe(['report-visible']) + ->and(concrete_filter_uuids(ReportFilter::class, Report::class, ['type' => 'custom'], 'int/v1/reports'))->toBe(['report-visible']) + ->and(concrete_filter_uuids(ReportFilter::class, Report::class, [], 'v1/reports'))->toBe(['report-visible']); +}); + test('category filter applies tenant core parent and list filters', function () { $capsule = concrete_filter_database(); $parentUuid = '11111111-1111-4111-8111-111111111111'; diff --git a/tests/Unit/SecurityBehavioralTest.php b/tests/Unit/SecurityBehavioralTest.php index d8be348f..75d870cd 100644 --- a/tests/Unit/SecurityBehavioralTest.php +++ b/tests/Unit/SecurityBehavioralTest.php @@ -308,6 +308,27 @@ function security_behavioral_bind_auth_guard(bool $validateResult = true): Secur return $guard; } +/** + * The trait deleteRecord also layers IAM directives onto the query; those need the + * users/permissions graph, so this stand-in leaves directives out and keeps the + * tenant clause (and its real schema column check) as the only scoping. + */ +class SecurityBehavioralApiCredential extends ApiCredential +{ + public function applyDirectivesToQuery(Request $request, $builder) + { + return $builder; + } +} + +function security_behavioral_credential_controller(): ApiCredentialController +{ + $controller = new ApiCredentialController(); + $controller->model = new SecurityBehavioralApiCredential(); + + return $controller; +} + afterEach(function () { session()->flush(); Carbon::setTestNow(); @@ -367,6 +388,30 @@ function security_behavioral_bind_auth_guard(bool $validateResult = true): Secur ->and(Notification::where('id', 'notif-theirs')->exists())->toBeTrue(); }); +test('default deleteRecord refuses to delete another company record', function () { + security_behavioral_database(); + + // ApiCredentialController inherits the trait deleteRecord; acting as company-1, + // company-2's credential must not resolve. + $request = security_behavioral_request('DELETE', '/v1/api-credentials/key_theirs'); + $response = security_behavioral_credential_controller()->deleteRecord('key_theirs', $request); + + expect($response->getStatusCode())->toBe(404) + ->and(ApiCredential::where('uuid', 'cred-theirs')->whereNull('deleted_at')->exists())->toBeTrue() + ->and(ApiCredential::where('uuid', 'cred-mine')->whereNull('deleted_at')->exists())->toBeTrue(); +}); + +test('default deleteRecord deletes a record owned by the active company', function () { + security_behavioral_database(); + + $request = security_behavioral_request('DELETE', '/v1/api-credentials/key_mine'); + $response = security_behavioral_credential_controller()->deleteRecord('key_mine', $request); + + expect($response->getStatusCode())->toBe(200) + ->and(ApiCredential::where('uuid', 'cred-mine')->whereNull('deleted_at')->exists())->toBeFalse() + ->and(ApiCredential::where('uuid', 'cred-theirs')->whereNull('deleted_at')->exists())->toBeTrue(); +}); + test('file download refuses to resolve another company file (IDOR)', function () { security_behavioral_database(); diff --git a/tests/Unit/Traits/HasApiControllerBehaviorTest.php b/tests/Unit/Traits/HasApiControllerBehaviorTest.php index 8ef2feef..c2672e5a 100644 --- a/tests/Unit/Traits/HasApiControllerBehaviorTest.php +++ b/tests/Unit/Traits/HasApiControllerBehaviorTest.php @@ -107,7 +107,8 @@ public function qualifyColumn($column) public function isColumn($column): bool { - return $this->hasCompanyColumn && str_ends_with($column, 'company_uuid'); + // Mirrors Schema::hasColumn: only the bare column name matches, never "table.column". + return $this->hasCompanyColumn && $column === 'company_uuid'; } public function applyDirectivesToQuery(Request $request, $builder) diff --git a/tests/Unit/Traits/HasApiModelBehaviorTest.php b/tests/Unit/Traits/HasApiModelBehaviorTest.php index f3b4f071..96f1f75b 100644 --- a/tests/Unit/Traits/HasApiModelBehaviorTest.php +++ b/tests/Unit/Traits/HasApiModelBehaviorTest.php @@ -836,6 +836,8 @@ public function total(): int expect($model->isColumn('company_uuid'))->toBeTrue() ->and($model->isColumn('missing_column'))->toBeFalse() + // isColumn takes a bare column name; a table-qualified name never matches. + ->and($model->isColumn($model->qualifyColumn('company_uuid')))->toBeFalse() ->and($model->shouldQualifyColumn('uuid'))->toBeTrue() ->and($model->shouldQualifyColumn('name'))->toBeFalse() ->and($model->isInvalidUpdateParam('name'))->toBeFalse() From 540ddcaf56d9e0636135a1d8948e34535a54aec6 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 15:43:18 +0800 Subject: [PATCH 23/51] fix(comments): publish unguards only for its own insert, so a failed insert no longer leaves every model unguarded in a long-lived worker --- src/Models/Comment.php | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/Models/Comment.php b/src/Models/Comment.php index c04ede11..f6f9c333 100644 --- a/src/Models/Comment.php +++ b/src/Models/Comment.php @@ -119,8 +119,6 @@ public function replies() */ public static function publish(array $attributes): Comment { - static::unguard(); - // If replies or parent is set somehow unset it unset($attributes['replies'], $attributes['parent']); @@ -139,9 +137,10 @@ public static function publish(array $attributes): Comment $attributes['created_at'] = now(); $attributes['updated_at'] = now(); - $comment = static::create($attributes); - static::reguard(); - - return $comment; + // Unguard only for this insert: `unguarded()` restores the guard even when the insert + // throws. A bare unguard() left every model in a long-lived (Octane) worker unguarded + // after a failed insert, so request params became where clauses and stray payload keys + // became insert columns on unrelated models. + return static::unguarded(fn () => static::create($attributes)); } } From f3fe0b13094360087d8bc01ea669ffe70770ab3c Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 17:00:37 +0800 Subject: [PATCH 24/51] test(comments): a failed publish leaves models guarded --- tests/Unit/Models/OperationalModelsTest.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/Unit/Models/OperationalModelsTest.php b/tests/Unit/Models/OperationalModelsTest.php index 4978d81d..af164fe8 100644 --- a/tests/Unit/Models/OperationalModelsTest.php +++ b/tests/Unit/Models/OperationalModelsTest.php @@ -636,6 +636,21 @@ public function clear(): bool Carbon::setTestNow(); }); +it('leaves models guarded when publishing a comment fails', function () { + operational_models_database(); + session([ + 'user' => 'user-1', + 'company' => 'company-1', + ]); + + // A duplicate primary key makes the insert throw. + Comment::publish(['uuid' => 'comment-dup', 'content' => 'First', 'subject_uuid' => 'order-1', 'subject_type' => User::class]); + + expect(fn () => Comment::publish(['uuid' => 'comment-dup', 'content' => 'Second', 'subject_uuid' => 'order-1', 'subject_type' => User::class])) + ->toThrow(Exception::class) + ->and(Comment::isUnguarded())->toBeFalse(); +}); + it('defines comment company parent and replies relationship contracts', function () { operational_models_database(); From 8efa86ade4df6607993fd298d247a20e2db3bc7f Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 20:10:29 +0800 Subject: [PATCH 25/51] fix(api-cache): first invalidation changes the list cache key A missing api_query_version key was read as 1 and Cache::increment creates it at 1, so the first invalidation for a table/company left list cache keys unchanged and stale lists were served until a second write. Missing versions now read as 0, and stores whose increment does not create missing keys get the version seeded. The version bump is what actually invalidates list caches: on Laravel 10 with phpredis >= 6.1, RedisTagSet::entries() starts ZSCAN at cursor '0', which phpredis treats as a finished scan, so the tag flush deletes the tag sets but leaves the tagged values in Redis until their TTL. --- src/Support/ApiModelCache.php | 39 ++++++++--- tests/Unit/Support/ApiModelCacheTest.php | 85 ++++++++++++++++++++---- 2 files changed, 102 insertions(+), 22 deletions(-) diff --git a/src/Support/ApiModelCache.php b/src/Support/ApiModelCache.php index f6c3e069..2453592d 100644 --- a/src/Support/ApiModelCache.php +++ b/src/Support/ApiModelCache.php @@ -17,6 +17,13 @@ * - Automatic cache invalidation * - Multi-tenancy support * - Cache tagging for efficient bulk invalidation + * + * List caches are invalidated by a per table/company query version embedded in + * the cache key, not by the tag flush. On Laravel 10 with phpredis >= 6.1, + * RedisTagSet::entries() starts ZSCAN with cursor '0', which phpredis treats as + * an already finished scan, so RedisTaggedCache::flush() deletes only the tag + * sets and leaves every tagged value in Redis until its TTL expires. Bumping the + * version moves readers to new keys and leaves the orphaned entries to expire. */ class ApiModelCache { @@ -89,14 +96,21 @@ public static function generateQueryCacheKey(Model $model, Request $request, arr // Get query version for this table/company // This ensures cache keys change after invalidation - $versionKey = "api_query_version:{$table}:{$companyUuid}"; - $version = Cache::get($versionKey, 1); // Default to version 1 + $version = Cache::get(static::queryVersionKey($table, $companyUuid), 0); // Add Redis hash tag {api_query} for Redis Cluster shard routing // Include version to ensure cache keys change after writes return "{api_query}:{$table}:{$companyPart}:v{$version}:{$paramsHash}"; } + /** + * Cache key holding the query version for a table/company. + */ + protected static function queryVersionKey(string $table, ?string $companyUuid): string + { + return "api_query_version:{$table}:{$companyUuid}"; + } + /** * Generate a cache key for a single model instance. * @@ -166,9 +180,8 @@ public static function cacheQueryResult(Model $model, Request $request, \Closure $ttl = $ttl ?? static::LIST_TTL; // FIX #3: Guard against read-after-invalidate in same request - // After invalidation, resetCacheStatus() sets $cacheStatus to null - // But we need a way to detect if invalidation happened in this request - // For now, we'll rely on tag flush working correctly + // After invalidation, resetCacheStatus() sets $cacheStatus to null and the + // query version has moved, so the key generated above is already the new one try { // Initialize cache status and key @@ -323,10 +336,15 @@ public static function invalidateModelCache(Model $model, ?string $companyUuid = static::resetCacheStatus(); // FIX #4: Increment query version counter - // This ensures the cache key changes after invalidation - $table = $model->getTable(); - $versionKey = "api_query_version:{$table}:{$companyUuid}"; - Cache::increment($versionKey); + // This ensures the cache key changes after invalidation, and it is what + // actually hides stale list caches (see the class docblock). A missing + // version reads as 0 and INCR creates it at 1, so the first invalidation + // also changes the key. Stores whose increment does not create missing + // keys (database, memcached) return false, so the version is seeded. + $versionKey = static::queryVersionKey($model->getTable(), $companyUuid); + if (Cache::increment($versionKey) === false) { + Cache::add($versionKey, 1) || Cache::increment($versionKey); + } // Generate tags for BOTH model and query caches // Model caches: single-record lookups @@ -339,7 +357,8 @@ public static function invalidateModelCache(Model $model, ?string $companyUuid = Cache::tags($modelTags)->flush(); // Flush query-level caches (collections, lists) - // This is CRITICAL - query caches must be explicitly flushed + // On Redis this removes the tag sets but not the values (see the class + // docblock); the version bump above is what invalidates list caches Cache::tags($queryTags)->flush(); } catch (\Exception $e) { Log::error('Failed to invalidate cache', [ diff --git a/tests/Unit/Support/ApiModelCacheTest.php b/tests/Unit/Support/ApiModelCacheTest.php index 9bfe9689..be205432 100644 --- a/tests/Unit/Support/ApiModelCacheTest.php +++ b/tests/Unit/Support/ApiModelCacheTest.php @@ -140,12 +140,14 @@ public function flush(): bool class ApiModelCacheTestStore { - public array $values = []; - public array $taggedValues = []; - public array $flushedTags = []; - public array $forgotten = []; - public bool $throwOnTags = false; - public bool $lockReturnsNull = false; + public array $values = []; + public array $taggedValues = []; + public array $flushedTags = []; + public array $forgotten = []; + public bool $throwOnTags = false; + public bool $lockReturnsNull = false; + public bool $flushRemovesValues = true; + public bool $incrementCreatesMissing = true; public function get(string $key, mixed $default = null): mixed { @@ -159,8 +161,23 @@ public function put(string $key, mixed $value): bool return true; } - public function increment(string $key): int + public function add(string $key, mixed $value): bool { + if (array_key_exists($key, $this->values)) { + return false; + } + + $this->values[$key] = $value; + + return true; + } + + public function increment(string $key): int|false + { + if (!$this->incrementCreatesMissing && !array_key_exists($key, $this->values)) { + return false; + } + $this->values[$key] = ($this->values[$key] ?? 0) + 1; return $this->values[$key]; @@ -202,7 +219,10 @@ public function forgetTagged(array $tags, string $key): void public function flushTagged(array $tags): void { - unset($this->taggedValues[$this->tagKey($tags)]); + // Laravel 10 RedisTaggedCache on phpredis >= 6.1 drops the tag sets but not the values + if ($this->flushRemovesValues) { + unset($this->taggedValues[$this->tagKey($tags)]); + } } private function tagKey(array $tags): string @@ -428,7 +448,7 @@ public function gc(int $max_lifetime): int|false expect($first->all())->toBe(['fresh-result']) ->and($calls)->toBe(1) ->and(ApiModelCache::getCacheStatus())->toBe('MISS') - ->and(ApiModelCache::getCacheKey())->toStartWith('{api_query}:orders:company_company-1:v1:'); + ->and(ApiModelCache::getCacheKey())->toStartWith('{api_query}:orders:company_company-1:v0:'); $second = ApiModelCache::cacheQueryResult($model, $request, function () use (&$calls) { $calls++; @@ -581,6 +601,47 @@ public function gc(int $max_lifetime): int|false expect($store->flushedTags)->toContain(['company:company-1']); }); +test('api model cache does not serve a list cached before the first invalidation when the tag flush leaves it behind', function () { + $store = api_model_cache_fixture(); + $store->flushRemovesValues = false; + $model = api_model_cache_model(); + $request = api_model_cache_request(['status' => 'active']); + + expect(ApiModelCache::cacheQueryResult($model, $request, fn () => collect(['stale'])))->toEqual(collect(['stale'])); + $keyBeforeInvalidation = ApiModelCache::getCacheKey(); + + ApiModelCache::invalidateModelCache($model, 'company-1'); + + expect(ApiModelCache::cacheQueryResult($model, $request, fn () => collect(['fresh'])))->toEqual(collect(['fresh'])) + ->and(ApiModelCache::getCacheStatus())->toBe('MISS') + ->and(ApiModelCache::getCacheKey())->not->toBe($keyBeforeInvalidation) + ->and($store->get('api_query_version:orders:company-1'))->toBe(1); + + ApiModelCache::invalidateModelCache($model, 'company-1'); + + expect(ApiModelCache::cacheQueryResult($model, $request, fn () => collect(['fresher'])))->toEqual(collect(['fresher'])) + ->and(ApiModelCache::getCacheStatus())->toBe('MISS') + ->and($store->get('api_query_version:orders:company-1'))->toBe(2); +}); + +test('api model cache seeds the query version on stores whose increment does not create missing keys', function () { + $store = api_model_cache_fixture(); + $store->flushRemovesValues = false; + $store->incrementCreatesMissing = false; + $model = api_model_cache_model(); + $request = api_model_cache_request(['status' => 'active']); + + ApiModelCache::cacheQueryResult($model, $request, fn () => collect(['stale'])); + ApiModelCache::invalidateModelCache($model, 'company-1'); + + expect($store->get('api_query_version:orders:company-1'))->toBe(1) + ->and(ApiModelCache::cacheQueryResult($model, $request, fn () => collect(['fresh'])))->toEqual(collect(['fresh'])); + + ApiModelCache::invalidateModelCache($model, 'company-1'); + + expect($store->get('api_query_version:orders:company-1'))->toBe(2); +}); + test('api model cache resolves tenant scope from authenticated user and request input fallbacks', function () { api_model_cache_fixture(); $model = api_model_cache_model(); @@ -594,9 +655,9 @@ public function gc(int $max_lifetime): int|false ], null); expect(ApiModelCache::generateQueryCacheKey($model, $userRequest)) - ->toStartWith('{api_query}:orders:company_company-from-user:v1:') + ->toStartWith('{api_query}:orders:company_company-from-user:v0:') ->and(ApiModelCache::generateQueryCacheKey($model, $inputRequest)) - ->toStartWith('{api_query}:orders:company_company-from-input:v1:'); + ->toStartWith('{api_query}:orders:company_company-from-input:v0:'); }); test('api model cache invalidation methods swallow tagged cache backend failures', function () { @@ -686,7 +747,7 @@ public function gc(int $max_lifetime): int|false ->and($second->pluck('uuid')->all())->toBe(['order-2']) ->and($callbackCalls)->toBe(1) ->and(ApiModelCache::getCacheStatus())->toBe('HIT') - ->and(ApiModelCache::getCacheKey())->toStartWith('{api_query}:orders:company_company-1:v1:') + ->and(ApiModelCache::getCacheKey())->toStartWith('{api_query}:orders:company_company-1:v0:') ->and(ApiModelCacheTraitTestModel::$mutatedRequestQueries)->toHaveCount(1) ->and(ApiModelCacheTraitTestModel::$mutatedRequestQueries[0])->toMatchArray([ 'status' => 'active', From f4a20cb1a7ad18a2ac5ba99dc19b3a51ee56d671 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 20:44:13 +0800 Subject: [PATCH 26/51] fix(api-cache): version model and relationship cache keys Single model and relationship caches had no version, so with the Redis tag flush leaving values behind they were served stale until their TTL after a write. Their keys now carry a per table api_model_version that invalidateModelCache() bumps alongside the query version. It is per table because findCached/findByPublicIdCached/loadCached are keyed by id and know no company. The increment-or-seed logic is shared via bumpVersion(). --- src/Support/ApiModelCache.php | 60 +++++++++++++++++------- tests/Unit/Support/ApiModelCacheTest.php | 47 +++++++++++++++---- 2 files changed, 81 insertions(+), 26 deletions(-) diff --git a/src/Support/ApiModelCache.php b/src/Support/ApiModelCache.php index 2453592d..9ce16762 100644 --- a/src/Support/ApiModelCache.php +++ b/src/Support/ApiModelCache.php @@ -18,8 +18,10 @@ * - Multi-tenancy support * - Cache tagging for efficient bulk invalidation * - * List caches are invalidated by a per table/company query version embedded in - * the cache key, not by the tag flush. On Laravel 10 with phpredis >= 6.1, + * Caches are invalidated by versions embedded in their keys, not by the tag + * flush: list caches carry a per table/company query version, single model and + * relationship caches a per table model version (their lookups are keyed by id + * and know no company). On Laravel 10 with phpredis >= 6.1, * RedisTagSet::entries() starts ZSCAN with cursor '0', which phpredis treats as * an already finished scan, so RedisTaggedCache::flush() deletes only the tag * sets and leaves every tagged value in Redis until its TTL expires. Bumping the @@ -111,6 +113,28 @@ protected static function queryVersionKey(string $table, ?string $companyUuid): return "api_query_version:{$table}:{$companyUuid}"; } + /** + * Cache key holding the model and relationship version for a table. + */ + protected static function modelVersionKey(string $table): string + { + return "api_model_version:{$table}"; + } + + /** + * Advance a cache version so keys built from it change. + * + * A missing version reads as 0 and INCR creates it at 1, so the first bump + * also changes the key. Stores whose increment does not create missing keys + * (database, memcached) return false, so the version is seeded instead. + */ + protected static function bumpVersion(string $versionKey): void + { + if (Cache::increment($versionKey) === false) { + Cache::add($versionKey, 1) || Cache::increment($versionKey); + } + } + /** * Generate a cache key for a single model instance. * @@ -120,9 +144,11 @@ public static function generateModelCacheKey(Model $model, $id, array $with = [] { $table = $model->getTable(); $withHash = !empty($with) ? ':' . md5(json_encode($with)) : ''; + $version = Cache::get(static::modelVersionKey($table), 0); // Add Redis hash tag {api_model} for Redis Cluster shard routing - return "{api_model}:{$table}:{$id}{$withHash}"; + // Include version to ensure cache keys change after writes + return "{api_model}:{$table}:v{$version}:{$id}{$withHash}"; } /** @@ -130,11 +156,13 @@ public static function generateModelCacheKey(Model $model, $id, array $with = [] */ public static function generateRelationshipCacheKey(Model $model, string $relationshipName): string { - $table = $model->getTable(); - $id = $model->getKey(); + $table = $model->getTable(); + $id = $model->getKey(); + $version = Cache::get(static::modelVersionKey($table), 0); // Add Redis hash tag {api_relation} for Redis Cluster shard routing - return "{api_relation}:{$table}:{$id}:{$relationshipName}"; + // Shares the model version, since relationships are invalidated with their parent + return "{api_relation}:{$table}:v{$version}:{$id}:{$relationshipName}"; } /** @@ -335,16 +363,12 @@ public static function invalidateModelCache(Model $model, ?string $companyUuid = // after invalidation within the same request lifecycle static::resetCacheStatus(); - // FIX #4: Increment query version counter - // This ensures the cache key changes after invalidation, and it is what - // actually hides stale list caches (see the class docblock). A missing - // version reads as 0 and INCR creates it at 1, so the first invalidation - // also changes the key. Stores whose increment does not create missing - // keys (database, memcached) return false, so the version is seeded. - $versionKey = static::queryVersionKey($model->getTable(), $companyUuid); - if (Cache::increment($versionKey) === false) { - Cache::add($versionKey, 1) || Cache::increment($versionKey); - } + // FIX #4: Increment the query and model version counters + // This ensures cache keys change after invalidation, and it is what + // actually hides stale caches (see the class docblock) + $table = $model->getTable(); + static::bumpVersion(static::queryVersionKey($table, $companyUuid)); + static::bumpVersion(static::modelVersionKey($table)); // Generate tags for BOTH model and query caches // Model caches: single-record lookups @@ -357,8 +381,8 @@ public static function invalidateModelCache(Model $model, ?string $companyUuid = Cache::tags($modelTags)->flush(); // Flush query-level caches (collections, lists) - // On Redis this removes the tag sets but not the values (see the class - // docblock); the version bump above is what invalidates list caches + // On Redis these flushes remove the tag sets but not the values (see the + // class docblock); the version bumps above are what invalidate caches Cache::tags($queryTags)->flush(); } catch (\Exception $e) { Log::error('Failed to invalidate cache', [ diff --git a/tests/Unit/Support/ApiModelCacheTest.php b/tests/Unit/Support/ApiModelCacheTest.php index be205432..047067fb 100644 --- a/tests/Unit/Support/ApiModelCacheTest.php +++ b/tests/Unit/Support/ApiModelCacheTest.php @@ -426,9 +426,9 @@ public function gc(int $max_lifetime): int|false expect($first)->toBe($second) ->and($first)->toStartWith('{api_query}:orders:company_company-1:v7:') ->and(ApiModelCache::generateModelCacheKey($model, 'order-1', ['customer', 'payload'])) - ->toBe('{api_model}:orders:order-1:' . md5(json_encode(['customer', 'payload']))) + ->toBe('{api_model}:orders:v0:order-1:' . md5(json_encode(['customer', 'payload']))) ->and(ApiModelCache::generateRelationshipCacheKey($model, 'payload')) - ->toBe('{api_relation}:orders:order-1:payload') + ->toBe('{api_relation}:orders:v0:order-1:payload') ->and(ApiModelCache::generateCacheTags($model, 'company-1', true)) ->toBe(['api_cache', 'api_model:orders', 'api_query:orders', 'company:company-1']); }); @@ -520,7 +520,7 @@ public function gc(int $max_lifetime): int|false ->and($secondModel)->toBe(['uuid' => 'order-1']) ->and($modelCalls)->toBe(1) ->and(ApiModelCache::getCacheStatus())->toBe('HIT') - ->and(ApiModelCache::getCacheKey())->toStartWith('{api_model}:orders:order-1:'); + ->and(ApiModelCache::getCacheKey())->toStartWith('{api_model}:orders:v0:order-1:'); ApiModelCache::resetCacheStatus(); $relationshipCalls = 0; @@ -539,7 +539,7 @@ public function gc(int $max_lifetime): int|false ->and($secondRelation)->toBe(['uuid' => 'payload-1']) ->and($relationshipCalls)->toBe(1) ->and(ApiModelCache::getCacheStatus())->toBe('HIT') - ->and(ApiModelCache::getCacheKey())->toBe('{api_relation}:orders:order-1:payload'); + ->and(ApiModelCache::getCacheKey())->toBe('{api_relation}:orders:v0:order-1:payload'); }); test('api model cache bypasses model relationship and invalidation work when disabled', function () { @@ -588,6 +588,7 @@ public function gc(int $max_lifetime): int|false expect(ApiModelCache::getCacheStatus())->toBeNull() ->and(ApiModelCache::getCacheKey())->toBeNull() ->and($store->get('api_query_version:orders:company-1'))->toBe(1) + ->and($store->get('api_model_version:orders'))->toBe(1) ->and($store->flushedTags)->toContain(['api_cache', 'api_model:orders', 'company:company-1']) ->and($store->flushedTags)->toContain(['api_cache', 'api_model:orders', 'api_query:orders', 'company:company-1']); @@ -624,7 +625,35 @@ public function gc(int $max_lifetime): int|false ->and($store->get('api_query_version:orders:company-1'))->toBe(2); }); -test('api model cache seeds the query version on stores whose increment does not create missing keys', function () { +test('api model cache does not serve model or relationship lookups cached before an invalidation when the tag flush leaves them behind', function () { + $store = api_model_cache_fixture(); + $store->flushRemovesValues = false; + $model = api_model_cache_model(); + + ApiModelCache::cacheModel($model, 'order-1', fn () => ['status' => 'stale'], ['payload']); + ApiModelCache::cacheRelationship($model, 'payload', fn () => ['name' => 'stale']); + $modelKeyBefore = ApiModelCache::generateModelCacheKey($model, 'order-1', ['payload']); + $relationKeyBefore = ApiModelCache::generateRelationshipCacheKey($model, 'payload'); + + ApiModelCache::invalidateModelCache($model, 'company-1'); + + expect(ApiModelCache::cacheModel($model, 'order-1', fn () => ['status' => 'fresh'], ['payload']))->toBe(['status' => 'fresh']) + ->and(ApiModelCache::getCacheStatus())->toBe('MISS') + ->and(ApiModelCache::getCacheKey())->not->toBe($modelKeyBefore) + ->and(ApiModelCache::cacheRelationship($model, 'payload', fn () => ['name' => 'fresh']))->toBe(['name' => 'fresh']) + ->and(ApiModelCache::getCacheStatus())->toBe('MISS') + ->and(ApiModelCache::getCacheKey())->not->toBe($relationKeyBefore) + ->and($store->get('api_model_version:orders'))->toBe(1); + + // Lookups know no company, so a write in any company moves the table's model version + ApiModelCache::invalidateModelCache($model, null); + + expect(ApiModelCache::cacheModel($model, 'order-1', fn () => ['status' => 'fresher'], ['payload']))->toBe(['status' => 'fresher']) + ->and(ApiModelCache::cacheRelationship($model, 'payload', fn () => ['name' => 'fresher']))->toBe(['name' => 'fresher']) + ->and($store->get('api_model_version:orders'))->toBe(2); +}); + +test('api model cache seeds versions on stores whose increment does not create missing keys', function () { $store = api_model_cache_fixture(); $store->flushRemovesValues = false; $store->incrementCreatesMissing = false; @@ -635,11 +664,13 @@ public function gc(int $max_lifetime): int|false ApiModelCache::invalidateModelCache($model, 'company-1'); expect($store->get('api_query_version:orders:company-1'))->toBe(1) + ->and($store->get('api_model_version:orders'))->toBe(1) ->and(ApiModelCache::cacheQueryResult($model, $request, fn () => collect(['fresh'])))->toEqual(collect(['fresh'])); ApiModelCache::invalidateModelCache($model, 'company-1'); - expect($store->get('api_query_version:orders:company-1'))->toBe(2); + expect($store->get('api_query_version:orders:company-1'))->toBe(2) + ->and($store->get('api_model_version:orders'))->toBe(2); }); test('api model cache resolves tenant scope from authenticated user and request input fallbacks', function () { @@ -693,13 +724,13 @@ public function gc(int $max_lifetime): int|false expect($modelResult)->toBe(['fallback' => true]) ->and(ApiModelCache::getCacheStatus())->toBe('ERROR') - ->and(ApiModelCache::getCacheKey())->toBe('{api_model}:orders:order-1'); + ->and(ApiModelCache::getCacheKey())->toBe('{api_model}:orders:v0:order-1'); $relationshipResult = ApiModelCache::cacheRelationship($model, 'payload', fn () => ['relationship' => true]); expect($relationshipResult)->toBe(['relationship' => true]) ->and(ApiModelCache::getCacheStatus())->toBe('ERROR') - ->and(ApiModelCache::getCacheKey())->toBe('{api_relation}:orders:order-1:payload'); + ->and(ApiModelCache::getCacheKey())->toBe('{api_relation}:orders:v0:order-1:payload'); }); test('api model cache warmup logs failures without leaking exceptions', function () { From 24228ce43364d6af51b1451fedb635d8f44f93dc Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 22:21:05 +0800 Subject: [PATCH 27/51] fix(api-cache): list cache keys cover the request path and session scope List cache keys were built from the table, the session company, the query string and has_callback only. Query callbacks that filter on session values (storefront_store, storefront_network) or on route parameters, and internal and public endpoints that paginate and eager load differently, therefore shared one cache entry per company and query: one store's products, catalogs, reviews or categories were served to another store or network of the same company, and an internal console paginator could be served to the public API (or the reverse). The params hash now also covers the request path and the values of the session keys listed in api.cache.scope.session (default: api_credential, storefront_store, storefront_network). Both are hashed, so credentials do not appear in the key or the X-Cache-Key debug header. --- config/api.php | 9 ++++ src/Support/ApiModelCache.php | 49 ++++++++++++++++- tests/Unit/Support/ApiModelCacheTest.php | 69 ++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 2 deletions(-) diff --git a/config/api.php b/config/api.php index 826cc4cc..16ca6cf0 100644 --- a/config/api.php +++ b/config/api.php @@ -60,6 +60,15 @@ 'relationship' => env('API_CACHE_RELATIONSHIP_TTL', 1800), ], + // List cache scope below the company + 'scope' => [ + // Session keys whose values are part of every list cache key, so lists + // filtered on them are not shared between their values. Add any key a + // query callback reads from the session but not from the query string. + // Default: the API credential and the Storefront store and network + 'session' => ['api_credential', 'storefront_store', 'storefront_network'], + ], + // Cache driver (uses Laravel's cache configuration) // Options: redis, memcached, database, file // Default: uses config('cache.default') diff --git a/src/Support/ApiModelCache.php b/src/Support/ApiModelCache.php index 9ce16762..2e1b99c9 100644 --- a/src/Support/ApiModelCache.php +++ b/src/Support/ApiModelCache.php @@ -59,8 +59,24 @@ class ApiModelCache */ public const RELATIONSHIP_TTL = 1800; + /** + * Session keys that scope list queries below the company (default). + * + * An API credential, or a Storefront store or network key, is resolved into + * these by middleware, and query callbacks filter on them without the value + * appearing in the query string. + */ + public const SESSION_SCOPE = ['api_credential', 'storefront_store', 'storefront_network']; + /** * Generate a cache key for a query. + * + * Besides the table, company and query string, the key covers the request path + * and the configured session scope. The path separates internal from public + * endpoints (which paginate and eager load differently) and endpoints whose + * callbacks read route parameters; the session scope separates API keys, stores + * and networks of one company. Both are hashed, so credentials never appear in + * the key or in the X-Cache-Key debug header. */ public static function generateQueryCacheKey(Model $model, Request $request, array $additionalParams = []): string { @@ -90,8 +106,12 @@ public static function generateQueryCacheKey(Model $model, Request $request, arr $params = array_filter($params, fn ($value) => $value !== null); ksort($params); - // Generate hash of parameters - $paramsHash = md5(json_encode($params)); + // Generate hash of parameters, the endpoint and the session scope + $paramsHash = md5(json_encode([ + 'params' => $params, + 'path' => $request->path(), + 'scope' => static::getSessionScope($request), + ])); // Include company UUID for multi-tenancy $companyPart = $companyUuid ? "company_{$companyUuid}" : 'no_company'; @@ -490,6 +510,31 @@ protected static function getCompanyUuid(Request $request): ?string return $request->input('company_uuid'); } + /** + * Extract the configured session scope values from request. + */ + protected static function getSessionScope(Request $request): array + { + if (!$request->hasSession()) { + return []; + } + + $keys = config('api.cache.scope.session') ?? static::SESSION_SCOPE; + $scope = []; + + foreach ($keys as $key) { + $value = $request->session()->get($key); + + if ($value !== null) { + $scope[$key] = $value; + } + } + + ksort($scope); + + return $scope; + } + /** * Warm up cache for a model. */ diff --git a/tests/Unit/Support/ApiModelCacheTest.php b/tests/Unit/Support/ApiModelCacheTest.php index 047067fb..98978d5b 100644 --- a/tests/Unit/Support/ApiModelCacheTest.php +++ b/tests/Unit/Support/ApiModelCacheTest.php @@ -266,6 +266,7 @@ function api_model_cache_fixture(array $config = []): ApiModelCacheTestStore 'api.cache.ttl.query' => 111, 'api.cache.ttl.model' => 222, 'api.cache.ttl.relationship' => 333, + 'api.cache.scope.session' => null, 'cache.default' => 'array', ], $config)); @@ -300,6 +301,16 @@ function api_model_cache_request(array $query = [], mixed $company = 'company-1' return $request; } +function api_model_cache_scoped_request(string $path, array $session, array $query = []): Request +{ + $request = Request::create($path, 'GET', $query); + $store = new Store('testing', new ApiModelCacheArraySessionHandler()); + $store->put($session); + $request->setLaravelSession($store); + + return $request; +} + function api_model_cache_internal_request(array $query = [], mixed $company = 'company-1'): Request { $request = api_model_cache_request($query, $company); @@ -673,6 +684,64 @@ public function gc(int $max_lifetime): int|false ->and($store->get('api_model_version:orders'))->toBe(2); }); +test('api model cache keeps lists of storefront stores and networks of the same company apart', function () { + api_model_cache_fixture(); + $model = api_model_cache_model(); + $query = ['limit' => '10']; + $callback = ['has_callback' => true]; + $storeA = api_model_cache_scoped_request('/storefront/v1/products', ['company' => 'company-1', 'storefront_store' => 'store-a', 'api_credential' => 'store_key_a'], $query); + $storeB = api_model_cache_scoped_request('/storefront/v1/products', ['company' => 'company-1', 'storefront_store' => 'store-b', 'api_credential' => 'store_key_b'], $query); + $network = api_model_cache_scoped_request('/storefront/v1/products', ['company' => 'company-1', 'storefront_network' => 'network-a', 'api_credential' => 'network_key_a'], $query); + + expect(ApiModelCache::cacheQueryResult($model, $storeA, fn () => collect(['store a product']), $callback))->toEqual(collect(['store a product'])); + $storeAKey = ApiModelCache::getCacheKey(); + + expect(ApiModelCache::cacheQueryResult($model, $storeB, fn () => collect(['store b product']), $callback))->toEqual(collect(['store b product'])) + ->and(ApiModelCache::getCacheStatus())->toBe('MISS') + ->and(ApiModelCache::cacheQueryResult($model, $network, fn () => collect(['network product']), $callback))->toEqual(collect(['network product'])) + ->and(ApiModelCache::getCacheStatus())->toBe('MISS') + ->and(ApiModelCache::cacheQueryResult($model, $storeA, fn () => collect(['not reached']), $callback))->toEqual(collect(['store a product'])) + ->and(ApiModelCache::getCacheStatus())->toBe('HIT') + ->and($storeAKey)->toStartWith('{api_query}:orders:company_company-1:v0:') + ->and($storeAKey)->not->toContain('store_key_a'); +}); + +test('api model cache keeps internal and public lists of the same company and query apart', function () { + api_model_cache_fixture(); + $model = api_model_cache_model(); + $query = ['limit' => '30']; + $callback = ['has_callback' => true]; + $internal = api_model_cache_scoped_request('/storefront/int/v1/catalogs', ['company' => 'company-1'], $query); + $public = api_model_cache_scoped_request('/storefront/v1/catalogs', ['company' => 'company-1', 'storefront_store' => 'store-a', 'api_credential' => 'store_key_a'], $query); + $console = api_model_cache_scoped_request('/storefront/int/v1/catalogs', ['company' => 'company-1'], $query); + + ApiModelCache::cacheQueryResult($model, $internal, fn () => 'internal paginator with editor relations', $callback); + + expect(ApiModelCache::cacheQueryResult($model, $public, fn () => collect(['store a catalog']), $callback))->toEqual(collect(['store a catalog'])) + ->and(ApiModelCache::getCacheStatus())->toBe('MISS') + ->and(ApiModelCache::cacheQueryResult($model, $console, fn () => 'not reached', $callback))->toBe('internal paginator with editor relations') + ->and(ApiModelCache::getCacheStatus())->toBe('HIT'); +}); + +test('api model cache keeps public lists of the same company and query on different endpoints apart', function () { + api_model_cache_fixture(); + $model = api_model_cache_model(); + $session = ['company' => 'company-1', 'api_credential' => 'credential-1']; + $key = fn (string $path) => ApiModelCache::generateQueryCacheKey($model, api_model_cache_scoped_request($path, $session, ['limit' => '10']), ['has_callback' => true]); + + expect($key('/v1/customers/customer_a/places'))->not->toBe($key('/v1/customers/customer_b/places')) + ->and($key('/v1/customers/customer_a/places'))->toBe($key('/v1/customers/customer_a/places')); +}); + +test('api model cache scopes list keys by the configured session keys only', function () { + api_model_cache_fixture(['api.cache.scope.session' => ['customer']]); + $model = api_model_cache_model(); + $key = fn (array $session) => ApiModelCache::generateQueryCacheKey($model, api_model_cache_scoped_request('/v1/orders', ['company' => 'company-1', ...$session])); + + expect($key(['customer' => 'customer-a']))->not->toBe($key(['customer' => 'customer-b'])) + ->and($key(['customer' => 'customer-a', 'storefront_store' => 'store-a']))->toBe($key(['customer' => 'customer-a', 'storefront_store' => 'store-b'])); +}); + test('api model cache resolves tenant scope from authenticated user and request input fallbacks', function () { api_model_cache_fixture(); $model = api_model_cache_model(); From 6914ef80337d9cd546365e3958d3aa3d95ccd2d9 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 21:58:22 +0800 Subject: [PATCH 28/51] feat(api-cache): version caches by the tables they embed A cached list stores the Eloquent result, including every relation the cached query eager loads, but its key only carried the parent table's version, so writes to embedded tables (e.g. catalogs embedded in the food truck list) were served stale until the TTL. Keys now carry the versions of the model's own table and of every table its cached result embeds, read in one Cache::many round trip: - tables a model declares in $apiCacheDependencies (relation paths, which include pivot and through tables, or raw table names) - the relations a list request loads or counts (with, expand, count, with_count) - the $with of a model lookup and the relation of a relationship lookup List keys read a table wide version next to the company one, so writes that know no company (pivots, manual invalidation, CLI) invalidate every company. Relation methods are resolved like Eloquent eager loading, on a fresh instance without constraints; client supplied paths never call framework methods and the memo is capped. An unresolvable declaration throws in debug and is logged and skipped otherwise. --- src/Support/ApiModelCache.php | 243 ++++++++++++++- src/Traits/HasApiModelCache.php | 19 ++ tests/Unit/Support/ApiModelCacheTest.php | 283 +++++++++++++++++- tests/Unit/Traits/HasApiModelBehaviorTest.php | 5 + 4 files changed, 533 insertions(+), 17 deletions(-) diff --git a/src/Support/ApiModelCache.php b/src/Support/ApiModelCache.php index 9ce16762..7083b3d5 100644 --- a/src/Support/ApiModelCache.php +++ b/src/Support/ApiModelCache.php @@ -3,9 +3,14 @@ namespace Fleetbase\Support; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasManyThrough; +use Illuminate\Database\Eloquent\Relations\MorphTo; +use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; /** * API Model Cache Manager. @@ -19,9 +24,15 @@ * - Cache tagging for efficient bulk invalidation * * Caches are invalidated by versions embedded in their keys, not by the tag - * flush: list caches carry a per table/company query version, single model and - * relationship caches a per table model version (their lookups are keyed by id - * and know no company). On Laravel 10 with phpredis >= 6.1, + * flush: list caches carry per table query versions (table wide and per company), + * single model and relationship caches per table model versions (their lookups + * are keyed by id and know no company). A key carries the versions of the + * model's own table and of every table its cached result embeds: the tables a + * model declares in `$apiCacheDependencies` (relation paths or table names), plus + * the relations a request (`with`, `expand`, `count`) or a model or relationship + * lookup loads. A write to any of those tables changes the key. Only what is + * loaded inside the cached callback goes stale; relations a resource lazy loads + * at render time are queried fresh on every hit. On Laravel 10 with phpredis >= 6.1, * RedisTagSet::entries() starts ZSCAN with cursor '0', which phpredis treats as * an already finished scan, so RedisTaggedCache::flush() deletes only the tag * sets and leaves every tagged value in Redis until its TTL expires. Bumping the @@ -59,6 +70,18 @@ class ApiModelCache */ public const RELATIONSHIP_TTL = 1800; + /** + * Resolved dependency tables, keyed by model class, mode and relation path. + * + * @var array + */ + protected static array $dependencyTables = []; + + /** + * Most resolved paths kept per process; request paths are client supplied. + */ + protected const DEPENDENCY_MEMO_LIMIT = 1000; + /** * Generate a cache key for a query. */ @@ -96,17 +119,27 @@ public static function generateQueryCacheKey(Model $model, Request $request, arr // Include company UUID for multi-tenancy $companyPart = $companyUuid ? "company_{$companyUuid}" : 'no_company'; - // Get query version for this table/company + // Get query versions for this table and the tables its responses embed // This ensures cache keys change after invalidation - $version = Cache::get(static::queryVersionKey($table, $companyUuid), 0); + $versionKeys = []; + foreach ([$table, ...static::dependencyTables($model, static::requestRelationPaths($request))] as $versionTable) { + $versionKeys[] = static::queryVersionKey($versionTable, null); + if ($companyUuid) { + $versionKeys[] = static::queryVersionKey($versionTable, $companyUuid); + } + } + $version = static::versionSegment($versionKeys); // Add Redis hash tag {api_query} for Redis Cluster shard routing // Include version to ensure cache keys change after writes - return "{api_query}:{$table}:{$companyPart}:v{$version}:{$paramsHash}"; + return "{api_query}:{$table}:{$companyPart}:{$version}:{$paramsHash}"; } /** * Cache key holding the query version for a table/company. + * + * A null company is the table wide version, which every company's list keys + * include, so writes that know no company invalidate all of them. */ protected static function queryVersionKey(string $table, ?string $companyUuid): string { @@ -121,6 +154,192 @@ protected static function modelVersionKey(string $table): string return "api_model_version:{$table}"; } + /** + * Read versions in one round trip and join them into a key segment. + * + * @param string[] $versionKeys + */ + protected static function versionSegment(array $versionKeys): string + { + $versions = Cache::many($versionKeys); + + return 'v' . implode('.', array_map(fn ($version) => (int) $version, $versions)); + } + + /** + * Get the other tables whose data a model's cached results embed. + * + * Declared dependencies come from the model's `$apiCacheDependencies` and must + * resolve (an entry that does not throws in debug and is logged and skipped + * otherwise); `$paths` are relations a request or lookup loads (`with`, `expand`, + * `$with`, a relationship name), and segments that cannot be resolved there + * end that path. + * + * @param string[] $paths + * + * @return string[] sorted table names, excluding the model's own table + */ + public static function dependencyTables(Model $model, array $paths = []): array + { + $declared = method_exists($model, 'getApiCacheDependencies') ? $model->getApiCacheDependencies() : []; + $tables = []; + + foreach ($declared as $path) { + array_push($tables, ...static::pathTables($model, $path, true)); + } + foreach ($paths as $path) { + array_push($tables, ...static::pathTables($model, $path, false)); + } + + $tables = array_values(array_unique(array_diff($tables, [$model->getTable()]))); + sort($tables); + + return $tables; + } + + /** + * Resolve one relation path, memoized per process. + * + * @return string[] + */ + protected static function pathTables(Model $model, string $path, bool $strict): array + { + $memoKey = get_class($model) . '|' . ($strict ? 'declared' : 'lookup') . '|' . $path; + if (array_key_exists($memoKey, static::$dependencyTables)) { + return static::$dependencyTables[$memoKey]; + } + + if (count(static::$dependencyTables) >= static::DEPENDENCY_MEMO_LIMIT) { + static::$dependencyTables = []; + } + + return static::$dependencyTables[$memoKey] = static::resolvePath($model, $path, $strict); + } + + /** + * Resolve a relation path (or, when declared, a table name) to tables. + * + * A relation contributes its related table, the pivot table of a many to many + * relation and the intermediate table of a has-many-through. A morphTo has no + * single related table, so declarations must name its tables instead. + * + * @return string[] + */ + protected static function resolvePath(Model $model, string $path, bool $strict): array + { + $tables = []; + $current = $model->newInstance(); + $segments = explode('.', $path); + + foreach ($segments as $segment) { + $method = static::relationMethod($current, trim($segment), $strict); + // Like Eloquent's eager loading: a fresh instance, without the relation's constraints + $relation = $method ? Relation::noConstraints(fn () => $current->newInstance()->{$method}()) : null; + + if (!$relation instanceof Relation || $relation instanceof MorphTo) { + if (!$strict) { + return $tables; + } + + // A declared entry that is not a relation name is a table name + if ($method === null && count($segments) === 1) { + return [$segment]; + } + + // Extensions deploy independently of core, so a bad declaration fails + // loudly in debug (tests, development) but only logs in production + $message = sprintf('%s::$apiCacheDependencies entry "%s" cannot be resolved at "%s"; declare a relation path or the table names it reads.', get_class($model), $path, $segment); + if (config('app.debug')) { + throw new \LogicException($message); + } + Log::error($message); + + return $tables; + } + + if ($relation instanceof BelongsToMany) { + $tables[] = $relation->getTable(); + } + if ($relation instanceof HasManyThrough) { + $tables[] = $relation->getParent()->getTable(); + } + + $current = $relation->getRelated(); + $tables[] = $current->getTable(); + } + + return $tables; + } + + /** + * Find the method a relation segment names, as requests spell it (camel or snake case). + * + * Request paths are client supplied, so only methods the model's own classes + * declare, with no required parameters, are considered. + */ + protected static function relationMethod(Model $model, string $segment, bool $strict): ?string + { + foreach (array_unique([$segment, Str::camel($segment), Str::snake($segment)]) as $method) { + if ($method === '' || !method_exists($model, $method)) { + continue; + } + + $reflection = new \ReflectionMethod($model, $method); + $declaredBy = $reflection->getDeclaringClass()->getName(); + if (!$reflection->isPublic() || $reflection->isStatic() || $reflection->getNumberOfRequiredParameters() > 0) { + continue; + } + if (!$strict && str_starts_with($declaredBy, 'Illuminate\\')) { + continue; + } + + return $method; + } + + return null; + } + + /** + * Get the relation paths a list request eager loads or counts. + * + * @return string[] + */ + protected static function requestRelationPaths(Request $request): array + { + $paths = []; + foreach (['with', 'expand', 'count', 'with_count'] as $param) { + $value = $request->input($param); + if (is_string($value)) { + $value = explode(',', $value); + } + if (is_array($value)) { + array_push($paths, ...array_filter($value, 'is_string')); + } + } + + return array_values(array_unique(array_map('trim', $paths))); + } + + /** + * Turn eager load definitions into relation paths. + * + * @param array $with + * + * @return string[] + */ + protected static function relationPaths(array $with): array + { + $paths = []; + foreach ($with as $key => $value) { + $path = is_string($key) ? $key : $value; + if (is_string($path)) { + $paths[] = explode(':', $path, 2)[0]; + } + } + + return $paths; + } + /** * Advance a cache version so keys built from it change. * @@ -144,11 +363,12 @@ public static function generateModelCacheKey(Model $model, $id, array $with = [] { $table = $model->getTable(); $withHash = !empty($with) ? ':' . md5(json_encode($with)) : ''; - $version = Cache::get(static::modelVersionKey($table), 0); + $tables = [$table, ...static::dependencyTables($model, static::relationPaths($with))]; + $version = static::versionSegment(array_map(fn ($versionTable) => static::modelVersionKey($versionTable), $tables)); // Add Redis hash tag {api_model} for Redis Cluster shard routing // Include version to ensure cache keys change after writes - return "{api_model}:{$table}:v{$version}:{$id}{$withHash}"; + return "{api_model}:{$table}:{$version}:{$id}{$withHash}"; } /** @@ -158,11 +378,12 @@ public static function generateRelationshipCacheKey(Model $model, string $relati { $table = $model->getTable(); $id = $model->getKey(); - $version = Cache::get(static::modelVersionKey($table), 0); + $tables = [$table, ...static::dependencyTables($model, [$relationshipName])]; + $version = static::versionSegment(array_map(fn ($versionTable) => static::modelVersionKey($versionTable), $tables)); // Add Redis hash tag {api_relation} for Redis Cluster shard routing - // Shares the model version, since relationships are invalidated with their parent - return "{api_relation}:{$table}:v{$version}:{$id}:{$relationshipName}"; + // Carries the parent's versions and the related tables', so a write to either side changes it + return "{api_relation}:{$table}:{$version}:{$id}:{$relationshipName}"; } /** diff --git a/src/Traits/HasApiModelCache.php b/src/Traits/HasApiModelCache.php index 195b89cf..5416696f 100644 --- a/src/Traits/HasApiModelCache.php +++ b/src/Traits/HasApiModelCache.php @@ -250,6 +250,25 @@ public function loadMultipleCached($relationships) return $this; } + /** + * Get the relation paths or table names this model's cached responses embed. + * + * Declare them on the model as `protected $apiCacheDependencies = ['catalogs', 'catalogs.products', 'some_table'];` + * so a write to any of those tables invalidates this model's caches. List only + * what the cached query loads (`$with`, filter or controller eager loads, counts); + * request `with`/`expand` paths are added automatically. Writes only invalidate + * when the model writing that table uses HasApiModelBehavior or this trait (for + * pivots, a `->using()` pivot class with one), goes through Eloquent events (not + * query builder updates, inserts or quiet saves), or is followed by + * invalidateApiCacheManually(). + * + * @return string[] + */ + public function getApiCacheDependencies(): array + { + return property_exists($this, 'apiCacheDependencies') ? (array) $this->apiCacheDependencies : []; + } + /** * Invalidate all caches for this model. */ diff --git a/tests/Unit/Support/ApiModelCacheTest.php b/tests/Unit/Support/ApiModelCacheTest.php index 047067fb..90a4cbc5 100644 --- a/tests/Unit/Support/ApiModelCacheTest.php +++ b/tests/Unit/Support/ApiModelCacheTest.php @@ -5,6 +5,7 @@ use Illuminate\Database\Capsule\Manager as Capsule; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\Pivot; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Events\Dispatcher; use Illuminate\Http\Request; @@ -80,6 +81,95 @@ class ApiModelCacheSoftDeletingTraitTestModel extends ApiModelCacheTraitTestMode use SoftDeletes; } +class ApiModelCacheDependencyPayload extends Model +{ + use HasApiModelCache; + + protected $table = 'payloads'; + protected $primaryKey = 'uuid'; + public $incrementing = false; + protected $keyType = 'string'; + protected $guarded = []; +} + +class ApiModelCacheDependencyTag extends Model +{ + protected $table = 'tags'; + protected $primaryKey = 'uuid'; + public $incrementing = false; + protected $keyType = 'string'; + protected $guarded = []; +} + +class ApiModelCacheDependencyOrderTag extends Pivot +{ + use HasApiModelCache; + + protected $table = 'order_tags'; + public $timestamps = false; +} + +class ApiModelCacheRelationsOrder extends ApiModelCacheTraitTestModel +{ + public function payload() + { + return $this->belongsTo(ApiModelCacheDependencyPayload::class, 'payload_uuid', 'uuid'); + } + + public function tags() + { + return $this->belongsToMany(ApiModelCacheDependencyTag::class, 'order_tags', 'order_uuid', 'tag_uuid') + ->using(ApiModelCacheDependencyOrderTag::class); + } + + public function subject() + { + return $this->morphTo(); + } + + public function tagPayloads() + { + return $this->hasManyThrough(ApiModelCacheDependencyPayload::class, ApiModelCacheDependencyTag::class, 'order_uuid', 'tag_uuid', 'uuid', 'uuid'); + } + + public function reconnectingPayload() + { + $this->setConnection('mysql'); + + return $this->belongsTo(ApiModelCacheDependencyPayload::class, 'payload_uuid', 'uuid'); + } + + public function summary(): string + { + return 'not a relation'; + } +} + +class ApiModelCacheDependencyOrder extends ApiModelCacheRelationsOrder +{ + protected $apiCacheDependencies = ['payload', 'tags', 'audit_logs']; +} + +class ApiModelCacheMorphDependencyOrder extends ApiModelCacheRelationsOrder +{ + protected $apiCacheDependencies = ['subject']; +} + +class ApiModelCacheUnresolvableDependencyOrder extends ApiModelCacheRelationsOrder +{ + protected $apiCacheDependencies = ['payload.customer']; +} + +class ApiModelCacheNotRelationDependencyOrder extends ApiModelCacheRelationsOrder +{ + protected $apiCacheDependencies = ['summary']; +} + +class ApiModelCachePartlyResolvableDependencyOrder extends ApiModelCacheRelationsOrder +{ + protected $apiCacheDependencies = ['tags', 'payload.customer']; +} + class ApiModelCacheTestLock { public function __construct(private ApiModelCacheTestStore $store) @@ -154,6 +244,16 @@ public function get(string $key, mixed $default = null): mixed return $this->values[$key] ?? $default; } + public function many(array $keys): array + { + $values = []; + foreach ($keys as $key) { + $values[$key] = $this->values[$key] ?? null; + } + + return $values; + } + public function put(string $key, mixed $value): bool { $this->values[$key] = $value; @@ -315,6 +415,31 @@ public function uri(): string return $request; } +function api_model_cache_dependency_database(): Capsule +{ + $capsule = api_model_cache_trait_database(); + $schema = $capsule->getConnection('mysql')->getSchemaBuilder(); + + $schema->table('orders', fn ($table) => $table->string('payload_uuid')->nullable()); + $schema->create('payloads', function ($table) { + $table->string('uuid')->primary(); + $table->string('company_uuid')->nullable(); + $table->string('name')->nullable(); + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + }); + $schema->create('tags', function ($table) { + $table->string('uuid')->primary(); + $table->string('name')->nullable(); + }); + $schema->create('order_tags', function ($table) { + $table->string('order_uuid'); + $table->string('tag_uuid'); + }); + + return $capsule; +} + function api_model_cache_trait_database(): Capsule { Model::clearBootedModels(); @@ -424,7 +549,7 @@ public function gc(int $max_lifetime): int|false ]), ['callback' => true]); expect($first)->toBe($second) - ->and($first)->toStartWith('{api_query}:orders:company_company-1:v7:') + ->and($first)->toStartWith('{api_query}:orders:company_company-1:v0.7:') ->and(ApiModelCache::generateModelCacheKey($model, 'order-1', ['customer', 'payload'])) ->toBe('{api_model}:orders:v0:order-1:' . md5(json_encode(['customer', 'payload']))) ->and(ApiModelCache::generateRelationshipCacheKey($model, 'payload')) @@ -448,7 +573,7 @@ public function gc(int $max_lifetime): int|false expect($first->all())->toBe(['fresh-result']) ->and($calls)->toBe(1) ->and(ApiModelCache::getCacheStatus())->toBe('MISS') - ->and(ApiModelCache::getCacheKey())->toStartWith('{api_query}:orders:company_company-1:v0:'); + ->and(ApiModelCache::getCacheKey())->toStartWith('{api_query}:orders:company_company-1:v0.0:'); $second = ApiModelCache::cacheQueryResult($model, $request, function () use (&$calls) { $calls++; @@ -595,7 +720,7 @@ public function gc(int $max_lifetime): int|false ApiModelCache::invalidateQueryCache($model, $request); expect($store->forgotten[0]['tags'])->toBe(['api_cache', 'api_model:orders', 'api_query:orders', 'company:company-1']) - ->and($store->forgotten[0]['key'])->toStartWith('{api_query}:orders:company_company-1:v1:'); + ->and($store->forgotten[0]['key'])->toStartWith('{api_query}:orders:company_company-1:v0.1:'); ApiModelCache::invalidateCompanyCache('company-1'); @@ -653,6 +778,152 @@ public function gc(int $max_lifetime): int|false ->and($store->get('api_model_version:orders'))->toBe(2); }); +test('api model cache resolves declared dependencies to related pivot and raw table names', function () { + api_model_cache_dependency_database(); + config(['app.debug' => true]); + + expect(ApiModelCache::dependencyTables(new ApiModelCacheDependencyOrder()))->toBe(['audit_logs', 'order_tags', 'payloads', 'tags']) + ->and(ApiModelCache::dependencyTables(new ApiModelCacheRelationsOrder()))->toBe([]) + // Lookup paths skip what they cannot resolve instead of failing the lookup + ->and(ApiModelCache::dependencyTables(new ApiModelCacheRelationsOrder(), ['payload', 'subject', 'missing', 'tags.missing'])) + ->toBe(['order_tags', 'payloads', 'tags']) + ->and(fn () => ApiModelCache::dependencyTables(new ApiModelCacheMorphDependencyOrder())) + ->toThrow(LogicException::class, 'entry "subject" cannot be resolved at "subject"') + ->and(fn () => ApiModelCache::dependencyTables(new ApiModelCacheUnresolvableDependencyOrder())) + ->toThrow(LogicException::class, 'entry "payload.customer" cannot be resolved at "customer"') + ->and(fn () => ApiModelCache::dependencyTables(new ApiModelCacheNotRelationDependencyOrder())) + ->toThrow(LogicException::class, 'entry "summary" cannot be resolved at "summary"'); +}); + +test('api model cache logs and skips an unresolvable declaration outside debug', function () { + api_model_cache_dependency_database(); + config(['app.debug' => false]); + $logger = new ApiModelCacheTestLogger(); + app()->instance('log', $logger); + Facade::clearResolvedInstance('log'); + + expect(ApiModelCache::dependencyTables(new ApiModelCachePartlyResolvableDependencyOrder()))->toBe(['order_tags', 'payloads', 'tags']) + ->and($logger->errors)->toHaveCount(1) + ->and($logger->errors[0]['message'])->toContain('entry "payload.customer" cannot be resolved at "customer"'); +}); + +test('api model cache resolves request style relation paths without side effects on the model', function () { + api_model_cache_dependency_database(); + $order = new ApiModelCacheRelationsOrder(); + + expect(ApiModelCache::dependencyTables($order, ['tag_payloads']))->toBe(['payloads', 'tags']) + // Framework methods named by a client are never called + ->and(ApiModelCache::dependencyTables($order, ['delete', 'push', 'touch', 'getTable']))->toBe([]) + ->and(ApiModelCache::dependencyTables($order, ['reconnecting_payload']))->toBe(['payloads']) + ->and($order->getConnectionName())->toBeNull(); +}); + +test('api model cache versions a list by the relations its request eager loads', function () { + api_model_cache_dependency_database(); + $store = app('cache'); + $store->flushRemovesValues = false; + $order = new ApiModelCacheRelationsOrder(); + $withPayload = api_model_cache_request(['with' => ['payload']]); + $expandTags = api_model_cache_request(['expand' => 'tags']); + $plain = api_model_cache_request(); + + ApiModelCache::cacheQueryResult($order, $withPayload, fn () => collect(['stale payload'])); + ApiModelCache::cacheQueryResult($order, $expandTags, fn () => collect(['stale tags'])); + ApiModelCache::cacheQueryResult($order, $plain, fn () => collect(['plain'])); + + ApiModelCache::invalidateModelCache(new ApiModelCacheDependencyPayload(), 'company-1'); + + expect(ApiModelCache::cacheQueryResult($order, $withPayload, fn () => collect(['fresh payload'])))->toEqual(collect(['fresh payload'])) + ->and(ApiModelCache::cacheQueryResult($order, $expandTags, fn () => collect(['unexpected'])))->toEqual(collect(['stale tags'])) + ->and(ApiModelCache::cacheQueryResult($order, $plain, fn () => collect(['unexpected'])))->toEqual(collect(['plain'])); + + ApiModelCache::invalidateModelCache(new ApiModelCacheDependencyOrderTag(), null); + + expect(ApiModelCache::cacheQueryResult($order, $expandTags, fn () => collect(['fresh tags'])))->toEqual(collect(['fresh tags'])) + ->and(ApiModelCache::cacheQueryResult($order, $plain, fn () => collect(['unexpected'])))->toEqual(collect(['plain'])); +}); + +test('api model cache does not serve a list after a write to a declared dependency of the same company or of no company', function () { + api_model_cache_dependency_database(); + $store = app('cache'); + $store->flushRemovesValues = false; + $order = new ApiModelCacheDependencyOrder(); + $request = api_model_cache_request(['status' => 'active']); + + ApiModelCache::cacheQueryResult($order, $request, fn () => collect(['stale'])); + + // Another company's write leaves this company's list alone + ApiModelCache::invalidateModelCache(new ApiModelCacheDependencyPayload(), 'company-2'); + + expect(ApiModelCache::cacheQueryResult($order, $request, fn () => collect(['unexpected'])))->toEqual(collect(['stale'])) + ->and(ApiModelCache::getCacheStatus())->toBe('HIT'); + + ApiModelCache::invalidateModelCache(new ApiModelCacheDependencyPayload(), 'company-1'); + + expect(ApiModelCache::cacheQueryResult($order, $request, fn () => collect(['fresh'])))->toEqual(collect(['fresh'])) + ->and(ApiModelCache::getCacheStatus())->toBe('MISS'); + + // Pivots carry no company, so their writes move the table wide version every company reads + ApiModelCache::invalidateModelCache(new ApiModelCacheDependencyOrderTag(), null); + + expect(ApiModelCache::cacheQueryResult($order, $request, fn () => collect(['fresher'])))->toEqual(collect(['fresher'])) + ->and(ApiModelCache::getCacheStatus())->toBe('MISS') + // orders, then audit_logs, order_tags, payloads, tags; each table wide then company-1 + ->and(ApiModelCache::getCacheKey())->toStartWith('{api_query}:orders:company_company-1:v0.0.0.0.1.0.0.1.0.0:'); +}); + +test('has api model cache invalidates a parent list when a dependency or custom pivot is written through eloquent', function () { + api_model_cache_dependency_database(); + $store = app('cache'); + $store->flushRemovesValues = false; + + Model::setEventDispatcher(new Dispatcher(app())); + Model::clearBootedModels(); + + $request = api_model_cache_request(['status' => 'active']); + $read = fn (string $value) => ApiModelCache::cacheQueryResult(new ApiModelCacheDependencyOrder(), $request, fn () => collect([$value])); + + $read('before payload write'); + $payload = ApiModelCacheDependencyPayload::create(['uuid' => 'payload-1', 'company_uuid' => 'company-1', 'name' => 'Payload']); + + expect($read('after payload create'))->toEqual(collect(['after payload create'])); + + $payload->update(['name' => 'Renamed']); + + expect($read('after payload update'))->toEqual(collect(['after payload update'])); + + $order = ApiModelCacheDependencyOrder::find('order-1'); + $order->tags()->attach('tag-1'); + + expect($read('after attach'))->toEqual(collect(['after attach'])); + + $order->tags()->detach('tag-1'); + + expect($read('after detach'))->toEqual(collect(['after detach'])) + ->and($read('unexpected'))->toEqual(collect(['after detach'])); + + Model::unsetEventDispatcher(); +}); + +test('api model cache does not serve relationship or eager loaded lookups after a write to the related table', function () { + api_model_cache_dependency_database(); + $store = app('cache'); + $store->flushRemovesValues = false; + $order = new ApiModelCacheRelationsOrder(['uuid' => 'order-1']); + + ApiModelCache::cacheRelationship($order, 'payload', fn () => ['name' => 'stale']); + ApiModelCache::cacheModel($order, 'order-1', fn () => ['payload' => 'stale'], ['payload:uuid,name']); + ApiModelCache::cacheModel($order, 'order-1', fn () => ['status' => 'cached']); + + ApiModelCache::invalidateModelCache(new ApiModelCacheDependencyPayload(), 'company-1'); + + expect(ApiModelCache::cacheRelationship($order, 'payload', fn () => ['name' => 'fresh']))->toBe(['name' => 'fresh']) + ->and(ApiModelCache::getCacheKey())->toBe('{api_relation}:orders:v0.1:order-1:payload') + ->and(ApiModelCache::cacheModel($order, 'order-1', fn () => ['payload' => 'fresh'], ['payload:uuid,name']))->toBe(['payload' => 'fresh']) + // A lookup that does not load the payload keeps its cache + ->and(ApiModelCache::cacheModel($order, 'order-1', fn () => ['status' => 'unexpected']))->toBe(['status' => 'cached']); +}); + test('api model cache seeds versions on stores whose increment does not create missing keys', function () { $store = api_model_cache_fixture(); $store->flushRemovesValues = false; @@ -686,9 +957,9 @@ public function gc(int $max_lifetime): int|false ], null); expect(ApiModelCache::generateQueryCacheKey($model, $userRequest)) - ->toStartWith('{api_query}:orders:company_company-from-user:v0:') + ->toStartWith('{api_query}:orders:company_company-from-user:v0.0:') ->and(ApiModelCache::generateQueryCacheKey($model, $inputRequest)) - ->toStartWith('{api_query}:orders:company_company-from-input:v0:'); + ->toStartWith('{api_query}:orders:company_company-from-input:v0.0:'); }); test('api model cache invalidation methods swallow tagged cache backend failures', function () { @@ -778,7 +1049,7 @@ public function gc(int $max_lifetime): int|false ->and($second->pluck('uuid')->all())->toBe(['order-2']) ->and($callbackCalls)->toBe(1) ->and(ApiModelCache::getCacheStatus())->toBe('HIT') - ->and(ApiModelCache::getCacheKey())->toStartWith('{api_query}:orders:company_company-1:v0:') + ->and(ApiModelCache::getCacheKey())->toStartWith('{api_query}:orders:company_company-1:v0.0:') ->and(ApiModelCacheTraitTestModel::$mutatedRequestQueries)->toHaveCount(1) ->and(ApiModelCacheTraitTestModel::$mutatedRequestQueries[0])->toMatchArray([ 'status' => 'active', diff --git a/tests/Unit/Traits/HasApiModelBehaviorTest.php b/tests/Unit/Traits/HasApiModelBehaviorTest.php index 96f1f75b..ba9fb5d8 100644 --- a/tests/Unit/Traits/HasApiModelBehaviorTest.php +++ b/tests/Unit/Traits/HasApiModelBehaviorTest.php @@ -58,6 +58,11 @@ public function get(string $key, mixed $default = null): mixed return $this->store[$key] ?? $default; } + public function many(array $keys): array + { + return array_combine($keys, array_map(fn ($key) => $this->store[$key] ?? null, $keys)); + } + public function put(string $key, mixed $value, mixed $ttl = null): bool { $this->store[$key] = $value; From 9fd8875e3d04acade0096663c5e5e573433505be Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 12:32:03 +0800 Subject: [PATCH 29/51] fix(api-cache): harden dependency resolution and invalidate again on commit A release review found four ways #301 could hurt production: - Client supplied with/expand names were resolved by calling the model method of that name. A public method without required arguments could be anything: ?with=dispatch would call Order::dispatch() on a blank order, which saves a row and fires OrderDispatched. Request paths now only call methods that declare an Eloquent Relation return type (58% of relation methods do; the rest add no versions, as before #301), and are capped at 50 per request. - A relation method throwing on a blank model escaped key generation and failed the request. Resolution now contains it, and key generation runs inside cacheQueryResult's fallback, so a cache outage while reading versions falls back to the query instead of a 500. - An unresolvable declaration threw whenever app.debug was on, which some self-hosted production installs run with. It now only throws when api.cache.strict_dependencies is set (for test suites); otherwise it is logged once per process and skipped. - Model events fire before the surrounding transaction commits, so a read in that window cached the old rows under the new version until the TTL. Versions are bumped again on commit, through the transactions manager all connections share, since the transaction is often on another connection than the written model's. --- config/api.php | 7 ++ src/Support/ApiModelCache.php | 120 +++++++++++++++---- tests/Unit/Support/ApiModelCacheTest.php | 141 +++++++++++++++++++++-- 3 files changed, 239 insertions(+), 29 deletions(-) diff --git a/config/api.php b/config/api.php index 16ca6cf0..2f962b39 100644 --- a/config/api.php +++ b/config/api.php @@ -69,6 +69,13 @@ 'session' => ['api_credential', 'storefront_store', 'storefront_network'], ], + // Throw when a model's $apiCacheDependencies entry cannot be resolved, instead + // of logging it and skipping the entry. Meant for test suites; keep it off in + // production so an extension's bad declaration never fails a request. + // Default: false + // Example: API_CACHE_STRICT_DEPENDENCIES=true + 'strict_dependencies' => env('API_CACHE_STRICT_DEPENDENCIES', false), + // Cache driver (uses Laravel's cache configuration) // Options: redis, memcached, database, file // Default: uses config('cache.default') diff --git a/src/Support/ApiModelCache.php b/src/Support/ApiModelCache.php index b6a1d576..5427c1e7 100644 --- a/src/Support/ApiModelCache.php +++ b/src/Support/ApiModelCache.php @@ -2,6 +2,8 @@ namespace Fleetbase\Support; +use Illuminate\Container\Container; +use Illuminate\Database\DatabaseTransactionsManager; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasManyThrough; @@ -91,6 +93,11 @@ class ApiModelCache */ protected const DEPENDENCY_MEMO_LIMIT = 1000; + /** + * Most relation paths read from one request; the rest add no versions. + */ + protected const REQUEST_PATH_LIMIT = 50; + /** * Generate a cache key for a query. * @@ -190,8 +197,8 @@ protected static function versionSegment(array $versionKeys): string * Get the other tables whose data a model's cached results embed. * * Declared dependencies come from the model's `$apiCacheDependencies` and must - * resolve (an entry that does not throws in debug and is logged and skipped - * otherwise); `$paths` are relations a request or lookup loads (`with`, `expand`, + * resolve (an entry that does not is logged and skipped, or throws when + * api.cache.strict_dependencies is on); `$paths` are relations a request or lookup loads (`with`, `expand`, * `$with`, a relationship name), and segments that cannot be resolved there * end that path. * @@ -252,9 +259,17 @@ protected static function resolvePath(Model $model, string $path, bool $strict): $segments = explode('.', $path); foreach ($segments as $segment) { - $method = static::relationMethod($current, trim($segment), $strict); - // Like Eloquent's eager loading: a fresh instance, without the relation's constraints - $relation = $method ? Relation::noConstraints(fn () => $current->newInstance()->{$method}()) : null; + $method = static::relationMethod($current, trim($segment), $strict); + $relation = null; + if ($method) { + try { + // Like Eloquent's eager loading: a fresh instance, without the relation's constraints + $relation = Relation::noConstraints(fn () => $current->newInstance()->{$method}()); + } catch (\Throwable $e) { + // A relation that cannot be built on a blank model adds no versions + $relation = null; + } + } if (!$relation instanceof Relation || $relation instanceof MorphTo) { if (!$strict) { @@ -266,10 +281,11 @@ protected static function resolvePath(Model $model, string $path, bool $strict): return [$segment]; } - // Extensions deploy independently of core, so a bad declaration fails - // loudly in debug (tests, development) but only logs in production + // Extensions deploy independently of core, so a bad declaration never + // fails a request; it is logged once per process, and throws only when + // api.cache.strict_dependencies is on (tests) $message = sprintf('%s::$apiCacheDependencies entry "%s" cannot be resolved at "%s"; declare a relation path or the table names it reads.', get_class($model), $path, $segment); - if (config('app.debug')) { + if (config('api.cache.strict_dependencies', false)) { throw new \LogicException($message); } Log::error($message); @@ -294,8 +310,12 @@ protected static function resolvePath(Model $model, string $path, bool $strict): /** * Find the method a relation segment names, as requests spell it (camel or snake case). * - * Request paths are client supplied, so only methods the model's own classes - * declare, with no required parameters, are considered. + * Only public, non-static methods without required parameters are considered. + * Request paths are client supplied, so for them a method is only called when it + * declares a Relation return type: an untyped method could be anything (e.g. + * Order::dispatch() saves and fires events), and calling it is never worth a + * cache version. Declared paths come from the model's own code and may name + * untyped relation methods. */ protected static function relationMethod(Model $model, string $segment, bool $strict): ?string { @@ -305,11 +325,10 @@ protected static function relationMethod(Model $model, string $segment, bool $st } $reflection = new \ReflectionMethod($model, $method); - $declaredBy = $reflection->getDeclaringClass()->getName(); if (!$reflection->isPublic() || $reflection->isStatic() || $reflection->getNumberOfRequiredParameters() > 0) { continue; } - if (!$strict && str_starts_with($declaredBy, 'Illuminate\\')) { + if (!$strict && !static::declaresRelationReturn($reflection)) { continue; } @@ -319,6 +338,18 @@ protected static function relationMethod(Model $model, string $segment, bool $st return null; } + /** + * Whether a method's declared return type is an Eloquent relation. + */ + protected static function declaresRelationReturn(\ReflectionMethod $method): bool + { + $type = $method->getReturnType(); + + return $type instanceof \ReflectionNamedType + && !$type->isBuiltin() + && is_a($type->getName(), Relation::class, true); + } + /** * Get the relation paths a list request eager loads or counts. * @@ -337,7 +368,7 @@ protected static function requestRelationPaths(Request $request): array } } - return array_values(array_unique(array_map('trim', $paths))); + return array_slice(array_values(array_unique(array_map('trim', $paths))), 0, static::REQUEST_PATH_LIMIT); } /** @@ -360,6 +391,47 @@ protected static function relationPaths(array $with): array return $paths; } + /** + * Bump versions again when the write's transaction commits. + * + * Model events fire before the surrounding transaction commits, so a read in + * between sees the old rows under the new version and caches them. The second + * bump on commit retires anything cached in that window. The transaction may be + * on another connection than the model's (e.g. a storefront model written + * inside a transaction on the default connection), so this defers through the + * transactions manager all connections share. Outside a transaction the + * immediate bump stands alone. + * + * @param string[] $versionKeys + */ + protected static function bumpVersionsAfterCommit(Model $model, array $versionKeys): void + { + $bump = function () use ($versionKeys) { + foreach ($versionKeys as $versionKey) { + static::bumpVersion($versionKey); + } + }; + + try { + $container = Container::getInstance(); + if ($container->bound('db.transactions')) { + $transactions = $container->make('db.transactions'); + if ($transactions instanceof DatabaseTransactionsManager && $transactions->callbackApplicableTransactions()->isNotEmpty()) { + $transactions->addCallback($bump); + } + + return; + } + + // No shared manager (e.g. a bare capsule): only the model's connection is known + if (Model::getConnectionResolver() !== null && $model->getConnection()->transactionLevel() > 0) { + $model->getConnection()->afterCommit($bump); + } + } catch (\Throwable $e) { + // Nothing to defer to; the immediate bump stands alone + } + } + /** * Advance a cache version so keys built from it change. * @@ -443,16 +515,19 @@ public static function cacheQueryResult(Model $model, Request $request, \Closure return $result ?? collect([]); // Guard against null } - $cacheKey = static::generateQueryCacheKey($model, $request, $additionalParams); - $companyUuid = static::getCompanyUuid($request); - $tags = static::generateCacheTags($model, $companyUuid, true); // Include query tag - $ttl = $ttl ?? static::LIST_TTL; + $cacheKey = null; + $ttl = $ttl ?? static::LIST_TTL; // FIX #3: Guard against read-after-invalidate in same request // After invalidation, resetCacheStatus() sets $cacheStatus to null and the - // query version has moved, so the key generated above is already the new one + // query version has moved, so the key generated below is already the new one try { + // Inside the try, so a cache outage while reading versions falls back to the query + $cacheKey = static::generateQueryCacheKey($model, $request, $additionalParams); + $companyUuid = static::getCompanyUuid($request); + $tags = static::generateCacheTags($model, $companyUuid, true); // Include query tag + // Initialize cache status and key static::$cacheStatus = null; static::$cacheKey = $cacheKey; @@ -607,9 +682,12 @@ public static function invalidateModelCache(Model $model, ?string $companyUuid = // FIX #4: Increment the query and model version counters // This ensures cache keys change after invalidation, and it is what // actually hides stale caches (see the class docblock) - $table = $model->getTable(); - static::bumpVersion(static::queryVersionKey($table, $companyUuid)); - static::bumpVersion(static::modelVersionKey($table)); + $table = $model->getTable(); + $versionKeys = [static::queryVersionKey($table, $companyUuid), static::modelVersionKey($table)]; + foreach ($versionKeys as $versionKey) { + static::bumpVersion($versionKey); + } + static::bumpVersionsAfterCommit($model, $versionKeys); // Generate tags for BOTH model and query caches // Model caches: single-record lookups diff --git a/tests/Unit/Support/ApiModelCacheTest.php b/tests/Unit/Support/ApiModelCacheTest.php index 8a50c482..09229d8f 100644 --- a/tests/Unit/Support/ApiModelCacheTest.php +++ b/tests/Unit/Support/ApiModelCacheTest.php @@ -3,8 +3,13 @@ use Fleetbase\Support\ApiModelCache; use Fleetbase\Traits\HasApiModelCache; use Illuminate\Database\Capsule\Manager as Capsule; +use Illuminate\Database\DatabaseTransactionsManager; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasManyThrough; +use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\Pivot; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Events\Dispatcher; @@ -111,28 +116,30 @@ class ApiModelCacheDependencyOrderTag extends Pivot class ApiModelCacheRelationsOrder extends ApiModelCacheTraitTestModel { - public function payload() + public static int $sideEffects = 0; + + public function payload(): BelongsTo { return $this->belongsTo(ApiModelCacheDependencyPayload::class, 'payload_uuid', 'uuid'); } - public function tags() + public function tags(): BelongsToMany { return $this->belongsToMany(ApiModelCacheDependencyTag::class, 'order_tags', 'order_uuid', 'tag_uuid') ->using(ApiModelCacheDependencyOrderTag::class); } - public function subject() + public function subject(): MorphTo { return $this->morphTo(); } - public function tagPayloads() + public function tagPayloads(): HasManyThrough { return $this->hasManyThrough(ApiModelCacheDependencyPayload::class, ApiModelCacheDependencyTag::class, 'order_uuid', 'tag_uuid', 'uuid', 'uuid'); } - public function reconnectingPayload() + public function reconnectingPayload(): BelongsTo { $this->setConnection('mysql'); @@ -143,6 +150,25 @@ public function summary(): string { return 'not a relation'; } + + // An untyped relation: declarations may name it, client paths may not + public function untypedPayload() + { + return $this->belongsTo(ApiModelCacheDependencyPayload::class, 'payload_uuid', 'uuid'); + } + + // Shaped like Order::dispatch(): public, no required arguments, side effects + public function dispatch(bool $save = true) + { + static::$sideEffects++; + + return $this; + } + + public function exploding(): BelongsTo + { + throw new RuntimeException('needs a loaded model'); + } } class ApiModelCacheDependencyOrder extends ApiModelCacheRelationsOrder @@ -150,6 +176,16 @@ class ApiModelCacheDependencyOrder extends ApiModelCacheRelationsOrder protected $apiCacheDependencies = ['payload', 'tags', 'audit_logs']; } +class ApiModelCacheUntypedDependencyOrder extends ApiModelCacheRelationsOrder +{ + protected $apiCacheDependencies = ['untypedPayload']; +} + +class ApiModelCacheExplodingDependencyOrder extends ApiModelCacheRelationsOrder +{ + protected $apiCacheDependencies = ['tags', 'exploding']; +} + class ApiModelCacheMorphDependencyOrder extends ApiModelCacheRelationsOrder { protected $apiCacheDependencies = ['subject']; @@ -235,6 +271,7 @@ class ApiModelCacheTestStore public array $flushedTags = []; public array $forgotten = []; public bool $throwOnTags = false; + public bool $throwOnMany = false; public bool $lockReturnsNull = false; public bool $flushRemovesValues = true; public bool $incrementCreatesMissing = true; @@ -246,6 +283,10 @@ public function get(string $key, mixed $default = null): mixed public function many(array $keys): array { + if ($this->throwOnMany) { + throw new RuntimeException('cache unavailable'); + } + $values = []; foreach ($keys as $key) { $values[$key] = $this->values[$key] ?? null; @@ -791,7 +832,7 @@ public function gc(int $max_lifetime): int|false test('api model cache resolves declared dependencies to related pivot and raw table names', function () { api_model_cache_dependency_database(); - config(['app.debug' => true]); + config(['api.cache.strict_dependencies' => true]); expect(ApiModelCache::dependencyTables(new ApiModelCacheDependencyOrder()))->toBe(['audit_logs', 'order_tags', 'payloads', 'tags']) ->and(ApiModelCache::dependencyTables(new ApiModelCacheRelationsOrder()))->toBe([]) @@ -806,9 +847,9 @@ public function gc(int $max_lifetime): int|false ->toThrow(LogicException::class, 'entry "summary" cannot be resolved at "summary"'); }); -test('api model cache logs and skips an unresolvable declaration outside debug', function () { +test('api model cache logs and skips an unresolvable declaration unless strict', function () { api_model_cache_dependency_database(); - config(['app.debug' => false]); + config(['api.cache.strict_dependencies' => false]); $logger = new ApiModelCacheTestLogger(); app()->instance('log', $logger); Facade::clearResolvedInstance('log'); @@ -818,6 +859,90 @@ public function gc(int $max_lifetime): int|false ->and($logger->errors[0]['message'])->toContain('entry "payload.customer" cannot be resolved at "customer"'); }); +test('api model cache never calls a method a client names unless it is declared to return a relation', function () { + api_model_cache_dependency_database(); + config(['api.cache.strict_dependencies' => false]); + ApiModelCacheRelationsOrder::$sideEffects = 0; + $order = new ApiModelCacheRelationsOrder(); + + expect(ApiModelCache::dependencyTables($order, ['dispatch', 'untypedPayload', 'untyped_payload', 'exploding', 'payload']))->toBe(['payloads']) + ->and(ApiModelCacheRelationsOrder::$sideEffects)->toBe(0) + // A declaration comes from the model's own code and may name an untyped relation + ->and(ApiModelCache::dependencyTables(new ApiModelCacheUntypedDependencyOrder()))->toBe(['payloads']); + + // A list request naming them still caches and versions what it can + $request = api_model_cache_request(['with' => ['dispatch', 'exploding', 'payload']]); + expect(ApiModelCache::cacheQueryResult($order, $request, fn () => collect(['listed'])))->toEqual(collect(['listed'])) + ->and(ApiModelCache::getCacheStatus())->toBe('MISS') + ->and(ApiModelCacheRelationsOrder::$sideEffects)->toBe(0); +}); + +test('api model cache contains a declared relation that throws on a blank model', function () { + api_model_cache_dependency_database(); + config(['api.cache.strict_dependencies' => false]); + $logger = new ApiModelCacheTestLogger(); + app()->instance('log', $logger); + Facade::clearResolvedInstance('log'); + + expect(ApiModelCache::dependencyTables(new ApiModelCacheExplodingDependencyOrder()))->toBe(['order_tags', 'tags']) + ->and($logger->errors)->toHaveCount(1) + ->and($logger->errors[0]['message'])->toContain('entry "exploding" cannot be resolved'); +}); + +test('api model cache falls back to the query when reading versions fails', function () { + $store = api_model_cache_fixture(); + $store->throwOnMany = true; + $model = api_model_cache_model(); + + expect(ApiModelCache::cacheQueryResult($model, api_model_cache_request(['status' => 'active']), fn () => collect(['direct'])))->toEqual(collect(['direct'])) + ->and(ApiModelCache::getCacheStatus())->toBe('MISS') + ->and($store->taggedValues)->toBe([]); +}); + +test('api model cache retires a list cached while the invalidating write was uncommitted', function () { + $capsule = api_model_cache_dependency_database(); + $store = app('cache'); + $store->flushRemovesValues = false; + $connection = $capsule->getConnection('mysql'); + $transactions = new DatabaseTransactionsManager(); + $connection->setTransactionManager($transactions); + app()->instance('db.transactions', $transactions); + $order = new ApiModelCacheDependencyOrder(); + $request = api_model_cache_request(['status' => 'active']); + + $connection->beginTransaction(); + ApiModelCache::invalidateModelCache(new ApiModelCacheDependencyPayload(), 'company-1'); + + // Another request reads before the commit and caches the old rows under the new version + ApiModelCache::cacheQueryResult($order, $request, fn () => collect(['uncommitted view'])); + expect($store->get('api_query_version:payloads:company-1'))->toBe(1); + + $connection->commit(); + + expect($store->get('api_query_version:payloads:company-1'))->toBe(2) + ->and(ApiModelCache::cacheQueryResult($order, $request, fn () => collect(['committed'])))->toEqual(collect(['committed'])); + + // The transaction may be on another connection than the written model's + $capsule->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => ''], 'other'); + $other = $capsule->getConnection('other'); + $other->setTransactionManager($transactions); + $other->beginTransaction(); + ApiModelCache::invalidateModelCache(new ApiModelCacheDependencyPayload(), 'company-1'); + ApiModelCache::cacheQueryResult($order, $request, fn () => collect(['uncommitted elsewhere'])); + $other->commit(); + + expect(ApiModelCache::cacheQueryResult($order, $request, fn () => collect(['committed elsewhere'])))->toEqual(collect(['committed elsewhere'])); + + // A rolled back write leaves only its immediate bump + $connection->beginTransaction(); + ApiModelCache::invalidateModelCache(new ApiModelCacheDependencyPayload(), 'company-1'); + $connection->rollBack(); + + expect($store->get('api_query_version:payloads:company-1'))->toBe(5); + + app()->forgetInstance('db.transactions'); +}); + test('api model cache resolves request style relation paths without side effects on the model', function () { api_model_cache_dependency_database(); $order = new ApiModelCacheRelationsOrder(); From b9093321d38d922c003488d96c70c8364ce3bc8c Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 12:44:43 +0800 Subject: [PATCH 30/51] fix(api-cache): invalidation never fails the write that triggered it Version bumps ran outside invalidateModelCache's try block, so a cache outage failed every write that invalidates: evented model writes, and with the extension PRs also quiet write paths such as location pings. A bump that fails inside an after-commit callback would also surface from commit() after the rows were written. Both now go through bumpVersions(), which logs the failure; the entries it would have retired expire within their TTL. --- src/Support/ApiModelCache.php | 34 ++++++++++++++++++------ tests/Unit/Support/ApiModelCacheTest.php | 21 +++++++++++++++ 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/Support/ApiModelCache.php b/src/Support/ApiModelCache.php index 5427c1e7..f4d50bcb 100644 --- a/src/Support/ApiModelCache.php +++ b/src/Support/ApiModelCache.php @@ -406,11 +406,7 @@ protected static function relationPaths(array $with): array */ protected static function bumpVersionsAfterCommit(Model $model, array $versionKeys): void { - $bump = function () use ($versionKeys) { - foreach ($versionKeys as $versionKey) { - static::bumpVersion($versionKey); - } - }; + $bump = fn () => static::bumpVersions($versionKeys, $model); try { $container = Container::getInstance(); @@ -432,6 +428,30 @@ protected static function bumpVersionsAfterCommit(Model $model, array $versionKe } } + /** + * Advance versions without ever failing the write that triggered them. + * + * Invalidation runs inside model events, after-commit callbacks and write paths + * such as location pings. A cache outage there must not fail or roll back the + * write; the cached entries it would have retired expire within their TTL. + * + * @param string[] $versionKeys + */ + protected static function bumpVersions(array $versionKeys, Model $model): void + { + try { + foreach ($versionKeys as $versionKey) { + static::bumpVersion($versionKey); + } + } catch (\Throwable $e) { + Log::error('Failed to advance API cache versions', [ + 'model' => get_class($model), + 'keys' => $versionKeys, + 'error' => $e->getMessage(), + ]); + } + } + /** * Advance a cache version so keys built from it change. * @@ -684,9 +704,7 @@ public static function invalidateModelCache(Model $model, ?string $companyUuid = // actually hides stale caches (see the class docblock) $table = $model->getTable(); $versionKeys = [static::queryVersionKey($table, $companyUuid), static::modelVersionKey($table)]; - foreach ($versionKeys as $versionKey) { - static::bumpVersion($versionKey); - } + static::bumpVersions($versionKeys, $model); static::bumpVersionsAfterCommit($model, $versionKeys); // Generate tags for BOTH model and query caches diff --git a/tests/Unit/Support/ApiModelCacheTest.php b/tests/Unit/Support/ApiModelCacheTest.php index 09229d8f..4b18eb44 100644 --- a/tests/Unit/Support/ApiModelCacheTest.php +++ b/tests/Unit/Support/ApiModelCacheTest.php @@ -272,6 +272,7 @@ class ApiModelCacheTestStore public array $forgotten = []; public bool $throwOnTags = false; public bool $throwOnMany = false; + public bool $throwOnIncrement = false; public bool $lockReturnsNull = false; public bool $flushRemovesValues = true; public bool $incrementCreatesMissing = true; @@ -315,6 +316,10 @@ public function add(string $key, mixed $value): bool public function increment(string $key): int|false { + if ($this->throwOnIncrement) { + throw new RuntimeException('cache unavailable'); + } + if (!$this->incrementCreatesMissing && !array_key_exists($key, $this->values)) { return false; } @@ -889,6 +894,22 @@ public function gc(int $max_lifetime): int|false ->and($logger->errors[0]['message'])->toContain('entry "exploding" cannot be resolved'); }); +test('api model cache invalidation never fails the write when the cache is unavailable', function () { + $store = api_model_cache_fixture(); + $store->throwOnIncrement = true; + $store->throwOnTags = true; + $logger = new ApiModelCacheTestLogger(); + app()->instance('log', $logger); + Facade::clearResolvedInstance('log'); + + ApiModelCache::invalidateModelCache(api_model_cache_model(), 'company-1'); + + expect($logger->errors)->toHaveCount(2) + ->and($logger->errors[0]['message'])->toBe('Failed to advance API cache versions') + ->and($logger->errors[0]['context']['keys'])->toBe(['api_query_version:orders:company-1', 'api_model_version:orders']) + ->and($logger->errors[1]['message'])->toBe('Failed to invalidate cache'); +}); + test('api model cache falls back to the query when reading versions fails', function () { $store = api_model_cache_fixture(); $store->throwOnMany = true; From 96059960e086020739176c9a05275147867b7373 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 13:01:35 +0800 Subject: [PATCH 31/51] feat(api-cache): withoutInvalidating() scope for coalesced high frequency writes Telemetry saves vehicles and trailers with model events on every device event. With dependency versioning, every list embedding vehicles (orders, storefront food trucks) would then be invalidated on every ping in any company with streaming devices, effectively uncached. withoutInvalidating() keeps the save's model events but skips the cache invalidation they trigger, so the caller can coalesce it (fleetops throttles it to once per window per table and company). --- src/Support/ApiModelCache.php | 32 +++++++++++++++++++++++- tests/Unit/Support/ApiModelCacheTest.php | 23 +++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/Support/ApiModelCache.php b/src/Support/ApiModelCache.php index f4d50bcb..1c272fe7 100644 --- a/src/Support/ApiModelCache.php +++ b/src/Support/ApiModelCache.php @@ -98,6 +98,11 @@ class ApiModelCache */ protected const REQUEST_PATH_LIMIT = 50; + /** + * Depth of withoutInvalidating() scopes currently running. + */ + protected static int $invalidationSuppressed = 0; + /** * Generate a cache key for a query. * @@ -690,7 +695,7 @@ public static function cacheRelationship(Model $model, string $relationshipName, */ public static function invalidateModelCache(Model $model, ?string $companyUuid = null): void { - if (!static::isCachingEnabled()) { + if (!static::isCachingEnabled() || static::$invalidationSuppressed > 0) { return; } @@ -730,6 +735,31 @@ public static function invalidateModelCache(Model $model, ?string $companyUuid = } } + /** + * Run a callback without invalidating API caches. + * + * For high frequency writes (telemetry pings) whose model events should still + * fire, but whose cache invalidation the caller coalesces itself, e.g. once per + * window per table. Everything else the callback writes is not invalidated + * either, so keep the callback to the write itself. + * + * @template T + * + * @param \Closure(): T $callback + * + * @return T + */ + public static function withoutInvalidating(\Closure $callback): mixed + { + static::$invalidationSuppressed++; + + try { + return $callback(); + } finally { + static::$invalidationSuppressed--; + } + } + /** * Invalidate cache for a specific query. */ diff --git a/tests/Unit/Support/ApiModelCacheTest.php b/tests/Unit/Support/ApiModelCacheTest.php index 4b18eb44..9d064e84 100644 --- a/tests/Unit/Support/ApiModelCacheTest.php +++ b/tests/Unit/Support/ApiModelCacheTest.php @@ -910,6 +910,29 @@ public function gc(int $max_lifetime): int|false ->and($logger->errors[1]['message'])->toBe('Failed to invalidate cache'); }); +test('api model cache skips invalidation inside a withoutInvalidating scope only', function () { + $store = api_model_cache_fixture(); + $model = api_model_cache_model(); + + $result = ApiModelCache::withoutInvalidating(function () use ($model) { + ApiModelCache::invalidateModelCache($model, 'company-1'); + // Nested scopes stay suppressed until the outermost one ends + ApiModelCache::withoutInvalidating(fn () => ApiModelCache::invalidateModelCache($model, 'company-1')); + ApiModelCache::invalidateModelCache($model, 'company-1'); + + return 'written'; + }); + + expect($result)->toBe('written') + ->and($store->get('api_query_version:orders:company-1'))->toBeNull(); + + // A callback that throws still ends the scope + expect(fn () => ApiModelCache::withoutInvalidating(fn () => throw new RuntimeException('write failed')))->toThrow(RuntimeException::class); + + ApiModelCache::invalidateModelCache($model, 'company-1'); + expect($store->get('api_query_version:orders:company-1'))->toBe(1); +}); + test('api model cache falls back to the query when reading versions fails', function () { $store = api_model_cache_fixture(); $store->throwOnMany = true; From 429224121f2fd3b987d723c7b57972da9e05f5bb Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 13:02:08 +0800 Subject: [PATCH 32/51] fix(api-logs): log storefront API requests and relate them to the store or network Storefront routes are mounted under storefront/v1/, but isPublicRequest only matched v1/ since 39a6760c, so LogApiRequests never logged storefront writes. Treat storefront/v1/ as public too (internal int/ routes stay excluded), and add the storefront store or network public id to the log's related column so a request can be traced back to the key that made it. --- src/Jobs/LogApiRequest.php | 6 +++ src/Support/Http.php | 5 ++- tests/Unit/Http/MiddlewareContractsTest.php | 38 +++++++++++++++++++ tests/Unit/Jobs/LogApiRequestTest.php | 37 ++++++++++++++++++ .../Support/TemplateStringAndHttpTest.php | 3 ++ 5 files changed, 87 insertions(+), 2 deletions(-) diff --git a/src/Jobs/LogApiRequest.php b/src/Jobs/LogApiRequest.php index 2699eabf..27322b0c 100644 --- a/src/Jobs/LogApiRequest.php +++ b/src/Jobs/LogApiRequest.php @@ -82,6 +82,12 @@ public static function getPayload(Request $request, $response): array $related[] = Utils::get($content, 'id'); } + // If a storefront key made the request, push the store or network public id to related + $storefrontPublicId = session('storefront_store_public_id') ?? session('storefront_network_public_id'); + if ($storefrontPublicId) { + $related[] = $storefrontPublicId; + } + // Get api credential from session $apiCredential = session('api_credential'); diff --git a/src/Support/Http.php b/src/Support/Http.php index eded33da..485cc7cf 100644 --- a/src/Support/Http.php +++ b/src/Support/Http.php @@ -49,8 +49,9 @@ public static function isPublicRequest(?Request $request = null): bool $uri = ltrim((string) $route->uri(), '/'); - // Only log true public API: starts with "v1/" - return Str::startsWith($uri, 'v1/'); + // Only true public APIs: the core "v1/" API and the storefront "storefront/v1/" API. + // Internal routes such as "int/v1/..." and "storefront/int/v1/..." never match. + return Str::startsWith($uri, ['v1/', 'storefront/v1/']); } /** diff --git a/tests/Unit/Http/MiddlewareContractsTest.php b/tests/Unit/Http/MiddlewareContractsTest.php index 111efe5f..69c6d47d 100644 --- a/tests/Unit/Http/MiddlewareContractsTest.php +++ b/tests/Unit/Http/MiddlewareContractsTest.php @@ -660,6 +660,10 @@ function middleware_contracts_log_middleware(bool $enabled = true): LogApiReques middleware_contracts_mutation_request('/int/v1/orders', 'int/v1/orders'), fn () => new JsonResponse(['id' => 'order_2'], 201) ); + $storefrontInternal = middleware_contracts_log_middleware()->handle( + middleware_contracts_mutation_request('/storefront/int/v1/orders', 'storefront/int/v1/orders'), + fn () => new JsonResponse(['id' => 'order_3'], 201) + ); $excluded = middleware_contracts_log_middleware()->handle( middleware_contracts_mutation_request('/v1/drivers/driver_1/track', 'v1/drivers/{driver}/track'), fn () => new JsonResponse(['id' => 'track_1'], 201) @@ -668,6 +672,7 @@ function middleware_contracts_log_middleware(bool $enabled = true): LogApiReques expect($disabled->getStatusCode())->toBe(201) ->and($read->getStatusCode())->toBe(204) ->and($internal->getStatusCode())->toBe(201) + ->and($storefrontInternal->getStatusCode())->toBe(201) ->and($excluded->getStatusCode())->toBe(201) ->and($bus->jobs)->toBeEmpty(); }); @@ -712,6 +717,39 @@ function middleware_contracts_log_middleware(bool $enabled = true): LogApiReques ]); }); + test('log api requests dispatches storefront mutation request logs related to the store', function () { + middleware_contracts_fixture(['api.version' => 'v1']); + session([ + 'company' => 'company-1', + 'api_credential' => 'store_live_key', + 'storefront_store' => 'store-uuid-1', + 'storefront_store_public_id' => 'store_abc123', + 'storefront_network' => null, + 'storefront_network_public_id' => null, + ]); + $bus = new MiddlewareContractsBusFake(); + app()->instance('bus', $bus); + app()->instance(BusDispatcher::class, $bus); + Facade::clearResolvedInstance('bus'); + Facade::clearResolvedInstance(BusDispatcher::class); + + $response = middleware_contracts_log_middleware()->handle( + middleware_contracts_mutation_request('/storefront/v1/customers/request-creation-code', 'storefront/v1/customers/request-creation-code'), + fn () => new JsonResponse(['status' => 'ok'], 200) + ); + + expect($response->getStatusCode())->toBe(200) + ->and($bus->jobs)->toHaveCount(1) + ->and($bus->jobs[0])->toBeInstanceOf(Fleetbase\Jobs\LogApiRequest::class) + ->and($bus->jobs[0]->payload)->toMatchArray([ + 'company_uuid' => 'company-1', + 'method' => 'POST', + 'path' => 'storefront/v1/customers/request-creation-code', + 'status_code' => 200, + 'related' => ['store_abc123'], + ]); + }); + test('log api requests swallows dispatch errors and keeps original response', function () { middleware_contracts_fixture(['api.version' => 'v1']); $bus = new MiddlewareContractsBusFake(); diff --git a/tests/Unit/Jobs/LogApiRequestTest.php b/tests/Unit/Jobs/LogApiRequestTest.php index 1bcdc8ea..c7e4a37a 100644 --- a/tests/Unit/Jobs/LogApiRequestTest.php +++ b/tests/Unit/Jobs/LogApiRequestTest.php @@ -224,6 +224,43 @@ function loggable_request(float $startedAt): Request ->and($payload)->not->toHaveKeys(['api_credential_uuid', 'access_token_id']); }); + it('relates storefront payloads to the store or network whose key made the request', function () { + bind_test_container(['api.version' => 'v1']); + session([ + 'api_credential' => 'store_live_key', + 'company' => 'company-1', + 'storefront_store' => 'store-uuid-1', + 'storefront_store_public_id' => 'store_abc123', + 'storefront_network' => null, + 'storefront_network_public_id' => null, + ]); + + $storePayload = LogApiRequest::getPayload( + loggable_request(microtime(true) - 0.01), + new JsonResponse(['id' => 'customer_1'], 201) + ); + + session([ + 'api_credential' => 'network_live_key', + 'company' => 'company-2', + 'storefront_store' => null, + 'storefront_store_public_id' => null, + 'storefront_network' => 'network-uuid-1', + 'storefront_network_public_id' => 'network_xyz789', + ]); + + $networkPayload = LogApiRequest::getPayload( + loggable_request(microtime(true) - 0.01), + new JsonResponse(['status' => 'ok'], 200) + ); + + expect($storePayload['related'])->toBe(['customer_1', 'store_abc123']) + ->and($storePayload['company_uuid'])->toBe('company-1') + ->and($storePayload)->not->toHaveKeys(['api_credential_uuid', 'access_token_id']) + ->and($networkPayload['related'])->toBe(['network_xyz789']) + ->and($networkPayload['company_uuid'])->toBe('company-2'); + }); + it('persists API request logs on the selected connection and clears response cache', function () { $database = log_api_request_database(); $cache = new LogApiRequestResponseCacheFake(); diff --git a/tests/Unit/Support/TemplateStringAndHttpTest.php b/tests/Unit/Support/TemplateStringAndHttpTest.php index 24cb6645..3429e638 100644 --- a/tests/Unit/Support/TemplateStringAndHttpTest.php +++ b/tests/Unit/Support/TemplateStringAndHttpTest.php @@ -71,6 +71,9 @@ function routed_request(string $uri, array $action = []): Request ->and(Http::isInternalRequest(routed_request('v1/orders')))->toBeFalse() ->and(Http::isPublicRequest(routed_request('v1/orders')))->toBeTrue() ->and(Http::isPublicRequest(routed_request('int/v1/orders')))->toBeFalse() + ->and(Http::isPublicRequest(routed_request('storefront/v1/customers/request-creation-code')))->toBeTrue() + ->and(Http::isPublicRequest(routed_request('storefront/int/v1/orders')))->toBeFalse() + ->and(Http::isPublicRequest(routed_request('storefront/orders')))->toBeFalse() ->and(Http::isInternalRequest(Request::create('/v1/orders')))->toBeFalse() ->and(Http::isPublicRequest(Request::create('/v1/orders')))->toBeFalse() ->and(Http::useSort('-created_at'))->toBe(['created_at', 'desc']) From f8b996b68729dd54e7c2c2863411701f2f8ece8b Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 13:13:49 +0800 Subject: [PATCH 33/51] test(api): a requested name that is not a relation must never run with/expand/with_count/count names reach Eloquent's eager loader, which calls $model->newInstance()->$name() for any public method with no required arguments. The new case uses a model whose dispatch() writes a row (as fleetops' Order::dispatch() does) and requests it through every path: list, find and create; with as array and string, expand, a nested path, with_count, count, and the base Model's saveInstance(). It fails today: dispatch() runs on each path and every request ends in a 500. --- tests/Unit/Traits/HasApiModelBehaviorTest.php | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/Unit/Traits/HasApiModelBehaviorTest.php b/tests/Unit/Traits/HasApiModelBehaviorTest.php index 96f1f75b..b6fd5865 100644 --- a/tests/Unit/Traits/HasApiModelBehaviorTest.php +++ b/tests/Unit/Traits/HasApiModelBehaviorTest.php @@ -363,6 +363,24 @@ class HasApiModelBehaviorChild extends Model public $timestamps = false; } +/** + * Mirrors fleetops' Order::dispatch(): a public method with no required arguments + * that writes a row and would fire events. A request naming it must never run it. + */ +class HasApiModelBehaviorSideEffectRecord extends HasApiModelBehaviorRecord +{ + public static int $dispatched = 0; + + public function dispatch(bool $save = true) + { + static::$dispatched++; + + $this->forceFill(['uuid' => 'junk-' . static::$dispatched, 'name' => 'Junk dispatch'])->saveQuietly(); + + return $this; + } +} + function has_api_model_behavior_database(): Capsule { EloquentModel::clearBootedModels(); @@ -1047,6 +1065,47 @@ public function total(): int ->and($countBuilder->toSql())->toContain('api_model_behavior_children', 'child_items_count'); }); +test('api model behavior never calls a requested method that is not a relation', function (array $input) { + $capsule = has_api_model_behavior_database(); + has_api_model_behavior_seed_records($capsule); + session(['user' => 'session-user', 'company' => 'company-a']); + HasApiModelBehaviorSideEffectRecord::$dispatched = 0; + + $model = new HasApiModelBehaviorSideEffectRecord(); + $errors = []; + $attempt = function (string $path, Closure $call) use (&$errors) { + try { + return $call(); + } catch (Throwable $e) { + $errors[$path] = get_class($e); + } + }; + + $results = $attempt('list', fn () => $model->queryFromRequest(has_api_model_behavior_request(array_merge(['company_uuid' => 'company-a'], $input)))); + $found = $attempt('find', fn () => $model->getById('record_alpha', null, has_api_model_behavior_request($input))); + $created = $attempt('create', fn () => $model->createRecordFromRequest(has_api_model_behavior_request(array_merge([ + 'api_model_behavior_record' => ['uuid' => 'record-created', 'public_id' => 'record_created', 'name' => 'Created'], + ], $input), method: 'POST'))); + + $records = $capsule->getConnection('mysql')->table('api_model_behavior_records'); + + expect(HasApiModelBehaviorSideEffectRecord::$dispatched)->toBe(0) + ->and((clone $records)->where('name', 'like', 'Junk%')->orWhereNull('name')->count())->toBe(0) + ->and($errors)->toBe([]) + ->and($results)->toHaveCount(2) + ->and($found->uuid)->toBe('record-1') + ->and($created->uuid)->toBe('record-created'); +})->with([ + 'with array' => [['with' => ['dispatch']]], + 'with string' => [['with' => 'dispatch']], + 'expand' => [['expand' => ['dispatch']]], + 'nested' => [['with' => ['childItems', 'child_items.dispatch']]], + 'with_count' => [['with_count' => 'dispatch']], + 'with_count array' => [['with_count' => ['dispatch']]], + 'count' => [['count' => 'dispatch']], + 'base model method' => [['with' => ['saveInstance']]], +]); + test('api model behavior covers cached queries and create update response relation loading', function () { $capsule = has_api_model_behavior_database(); has_api_model_behavior_seed_records($capsule); From c4342f290c78fd818cfadc79eef5c8aa392fa0bb Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 13:13:49 +0800 Subject: [PATCH 34/51] fix(api): only real relations can be requested through with, expand and counts Eloquent resolves an eager load or a count by calling $model->newInstance()->{$name}(), so every request-supplied relation name ran whatever public no-argument method it named on a blank model: `with[]=saveInstance` inserted an empty row on any model, and on fleetops orders `with[]=dispatch` saved an order and queued OrderDispatched. Fleetbase\Support\RequestRelations now decides which names reach Eloquent. A segment is accepted when the method, or the Expandable closure registered under that name, declares an Eloquent Relation return type (a union counts when one member is a Relation), or when the model lists it in a new `$withable` array. The method must be public, non-static, and need no arguments. Each segment of a nested path is checked against the related model, and the path is cut at the first segment that fails. Nothing after a morphTo is kept, since its related model is only known per result, and a morphTo is never counted. Names come back as declared, so `subfleets` loads as `subFleets`. Rejected names are dropped without an error. The guard covers every place a client's names reach Eloquent: - applyCustomFilters() - withRelationships() and withCounts() - the load()/loadCount() after create and update - mutateModelWithRequest() - TemplateQuery::execute(), whose stored `with` list is user-editable withCounts() also accepts an array now; before, it was a TypeError. `without` in mutateModelWithRequest() now uses makeHidden() instead of setHidden(). setHidden() replaced the model's own hidden attributes rather than adding to them. Fixtures with untyped relations gain return types. The nested-path expectation drops `childItems.grandChildren`, a relation that never existed. --- CHANGELOG.md | 7 + src/Models/TemplateQuery.php | 4 +- src/Support/RequestRelations.php | 209 ++++++++++++++++++ src/Traits/HasApiModelBehavior.php | 92 ++------ tests/Unit/Models/TemplateModelsTest.php | 18 +- tests/Unit/Support/RequestRelationsTest.php | 204 +++++++++++++++++ tests/Unit/Traits/HasApiModelBehaviorTest.php | 25 ++- 7 files changed, 482 insertions(+), 77 deletions(-) create mode 100644 src/Support/RequestRelations.php create mode 100644 tests/Unit/Support/RequestRelationsTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index db652f5e..03d89dcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - `FleetbaseResource::resolve()` applies registered transformers to every resource, nested resource and collection item; `FleetbaseResourceCollection` resolves items (instead of calling `toArray()`), sharing one `prepare()` pass per collection. A hand-built collection with a manually set `preserveKeys` now filters item arrays with the item's flag. - `ResourceLifecycleEvent::getEventData()` and `broadcastWith()`, chat participant broadcast events, `Utils::serializeJsonResource()` and the cached internal user payload serialize through `resolve()`, so transformers apply to webhook and broadcast payloads and conditional `MissingValue`s are no longer emitted as `{}`. - `Find::httpResourceForModel()` caches internal and public resolutions separately. +- Untyped relation methods can no longer be requested by a client (see Security). Give the method a return type (`public function driver(): BelongsTo`) or list its name in `protected $withable = [...]`. +- A relation requested in another case (`subfleets`) loads under the declared method name (`subFleets`), the key resources read. +- `without` on create, update and public list responses adds to the model's hidden attributes (`makeHidden`) instead of replacing them (`setHidden`), so it can no longer un-hide the model's defaults. + +### Security +- Request `with` / `expand` / `with_count` / `count` names, and a template query's stored `with` list, reach Eloquent only when they name a real relation. That means a method or Expandable closure that declares an Eloquent Relation return type (a union counts when one member is a Relation), or a name the model lists in a new `$withable` array. Each segment of a nested path is checked against the related model, and the path is cut at the first segment that fails. Nothing after a `morphTo` is kept, and a `morphTo` is never counted. Anything else is dropped without an error. Previously Eloquent called any requested public method with no required arguments on a blank model: `with[]=saveInstance` inserted an empty row, and fleetops' `Order::dispatch()` saved an order and queued its events. See `Fleetbase\Support\RequestRelations`. ### Removed +- `HasApiModelBehavior::normalizeRequestRelations()` (protected, no callers outside the trait). - Legacy duck-typed transformers (`$target` property + static `output($model, $data)`), `ResourceTransformerRegistry::transform(Model, array)`, `resolveByTarget()`, `fixClassName()` and the static `$transformers` array. The `User` resource no longer calls the registry directly. - Adds first version diff --git a/src/Models/TemplateQuery.php b/src/Models/TemplateQuery.php index 6c1641b9..225de5c3 100644 --- a/src/Models/TemplateQuery.php +++ b/src/Models/TemplateQuery.php @@ -4,6 +4,7 @@ use Fleetbase\Casts\Json; use Fleetbase\Services\TemplateRenderService; +use Fleetbase\Support\RequestRelations; use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasPublicId; use Fleetbase\Traits\HasUuid; @@ -180,7 +181,8 @@ public function execute(): \Illuminate\Support\Collection } // Eager-load relationships - $with = $this->getAttribute('with'); + // The stored list is user-editable, so it is held to the same relation check as a request's `with` + $with = RequestRelations::eagerLoads($model, $this->getAttribute('with')); if (!empty($with)) { $query->with($with); } diff --git a/src/Support/RequestRelations.php b/src/Support/RequestRelations.php new file mode 100644 index 00000000..4c62344c --- /dev/null +++ b/src/Support/RequestRelations.php @@ -0,0 +1,209 @@ +newInstance()->{$name}()`, so an + * unchecked name runs any public method that needs no arguments on a blank model: `with[]=saveInstance` + * inserts an empty row, and fleetops' `Order::dispatch()` saves an order and queues its events. + * + * A name is accepted when the method (or the Expandable closure registered under that name) declares an + * Eloquent Relation return type, or when the model lists it in a `$withable` array. Each segment of a + * nested path is checked against the model the previous segment relates to, and the path is cut at the + * first segment that fails. Nothing after a morphTo is kept, since its related model is only known per + * result. Rejected names are dropped without an error. + */ +class RequestRelations +{ + /** + * Most paths read from one request. + */ + public const MAX_PATHS = 50; + + /** + * Most segments kept in one nested path. + */ + public const MAX_DEPTH = 5; + + /** + * Get the eager load paths a request may apply to a model. + * + * @param mixed $relations a comma-separated string or a list of dot paths + * + * @return string[] the accepted paths, with each segment named as the model's method + */ + public static function eagerLoads(Model $model, mixed $relations): array + { + $paths = []; + foreach (static::split($relations) as $path) { + $current = $model; + $segments = []; + foreach (explode('.', $path) as $segment) { + $method = static::relationMethod($current, $segment); + $relation = $method ? static::relation($current, $method) : null; + if (!$relation) { + break; + } + + $segments[] = $method; + if ($relation instanceof MorphTo || count($segments) >= static::MAX_DEPTH) { + break; + } + + $current = $relation->getRelated(); + } + + if ($segments) { + $paths[] = implode('.', $segments); + } + } + + return array_values(array_unique($paths)); + } + + /** + * Get the relation counts a request may apply to a model. + * + * @param mixed $relations a comma-separated string or a list of relation names + * + * @return string[] the accepted relation names, named as the model's methods + */ + public static function counts(Model $model, mixed $relations): array + { + $names = []; + foreach (static::split($relations) as $name) { + $method = str_contains($name, '.') ? null : static::relationMethod($model, $name); + if ($method === null) { + continue; + } + + $relation = static::relation($model, $method); + if ($relation && !$relation instanceof MorphTo) { + $names[] = $method; + } + } + + return array_values(array_unique($names)); + } + + /** + * Resolve a requested segment to the name of a relation method on the model. + * + * The segment is tried camel-cased, as given, then snake-cased. A method is returned as declared, so + * `subfleets` resolves to `subFleets` and the relation is loaded under the key resources read. + */ + public static function relationMethod(Model $model, string $segment): ?string + { + $segment = trim($segment); + if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $segment)) { + return null; + } + + $withable = array_map('strtolower', static::withable($model)); + foreach (array_unique([Str::camel($segment), $segment, Str::snake($segment)]) as $name) { + $callable = static::callable($model, $name); + if (!$callable) { + continue; + } + + $name = $callable instanceof \ReflectionMethod ? $callable->getName() : $name; + if (in_array(strtolower($name), $withable, true) || static::declaresRelation($callable->getReturnType())) { + return $name; + } + } + + return null; + } + + /** + * Flatten every accepted input shape to a list of trimmed, non-empty paths. + * + * @return string[] + */ + protected static function split(mixed $relations): array + { + if (is_string($relations)) { + $relations = explode(',', $relations); + } + if (!is_array($relations)) { + return []; + } + + $paths = array_map('trim', array_filter($relations, 'is_string')); + + return array_slice(array_values(array_unique(array_filter($paths, fn (string $path) => $path !== ''))), 0, static::MAX_PATHS); + } + + /** + * Get the method, or the Expandable closure, a relation name calls, when it can be called with no arguments. + */ + protected static function callable(Model $model, string $name): ?\ReflectionFunctionAbstract + { + if (method_exists($model, $name)) { + $method = new \ReflectionMethod($model, $name); + + return $method->isPublic() && !$method->isStatic() && $method->getNumberOfRequiredParameters() === 0 ? $method : null; + } + + if (method_exists($model, 'isExpansion') && $model::isExpansion($name)) { + $closure = $model::getExpansionClosure($name); + if (!$closure instanceof \Closure) { + return null; + } + + $closure = new \ReflectionFunction($closure); + + return $closure->getNumberOfRequiredParameters() === 0 ? $closure : null; + } + + return null; + } + + /** + * Whether a declared return type names an Eloquent relation (`BelongsTo`, `?HasMany`, `BelongsTo|Builder`). + */ + protected static function declaresRelation(?\ReflectionType $type): bool + { + $types = $type instanceof \ReflectionUnionType ? $type->getTypes() : [$type]; + foreach ($types as $member) { + if ($member instanceof \ReflectionNamedType && !$member->isBuiltin() && is_a($member->getName(), Relation::class, true)) { + return true; + } + } + + return false; + } + + /** + * Get the relation names a model allows beyond those with a declared Relation return type. + * + * @return string[] + */ + protected static function withable(Model $model): array + { + $withable = (fn () => property_exists($this, 'withable') ? $this->withable : [])->call($model); + + return is_array($withable) ? array_values(array_filter($withable, 'is_string')) : []; + } + + /** + * Build the relation the same way Eloquent does for an eager load, or null when it is not one. + */ + protected static function relation(Model $model, string $method): ?Relation + { + try { + $relation = Relation::noConstraints(fn () => $model->newInstance()->{$method}()); + } catch (\Throwable) { + return null; + } + + return $relation instanceof Relation ? $relation : null; + } +} diff --git a/src/Traits/HasApiModelBehavior.php b/src/Traits/HasApiModelBehavior.php index 89c29cd3..29c8545a 100644 --- a/src/Traits/HasApiModelBehavior.php +++ b/src/Traits/HasApiModelBehavior.php @@ -7,6 +7,7 @@ use Fleetbase\Support\Auth; use Fleetbase\Support\Http; use Fleetbase\Support\QueryOptimizer; +use Fleetbase\Support\RequestRelations; use Fleetbase\Support\Resolve; use Fleetbase\Support\Utils; use Illuminate\Http\JsonResponse; @@ -353,13 +354,13 @@ public function createRecordFromRequest($request, ?callable $onBefore = null, ?c // PERFORMANCE OPTIMIZATION: Use load() instead of re-querying the database // This avoids an unnecessary second database query - $with = static::normalizeRequestRelations($request->or(['with', 'expand'], [])); + $with = RequestRelations::eagerLoads($record, $request->or(['with', 'expand'], [])); if (!empty($with)) { $record->load($with); } // Load counts if requested - $withCount = $request->array('with_count', []); + $withCount = RequestRelations::counts($record, $request->input('with_count', [])); if (!empty($withCount)) { $record->loadCount($withCount); } @@ -456,13 +457,13 @@ public function updateRecordFromRequest(Request $request, $id, ?callable $onBefo // PERFORMANCE OPTIMIZATION: Use load() instead of re-querying the database // This avoids an unnecessary second database query - $with = static::normalizeRequestRelations($request->or(['with', 'expand'], [])); + $with = RequestRelations::eagerLoads($record, $request->or(['with', 'expand'], [])); if (!empty($with)) { $record->load($with); } // Load counts if requested - $withCount = $request->array('with_count', []); + $withCount = RequestRelations::counts($record, $request->input('with_count', [])); if (!empty($withCount)) { $record->loadCount($withCount); } @@ -548,9 +549,6 @@ public function bulkRemove($ids = []) */ public static function mutateModelWithRequest(Request $request, $result) { - $with = static::normalizeRequestRelations($request->or(['with', 'expand'], [])); - $without = $request->array('without'); - // handle collection or array of results if (is_array($result) || $result instanceof \Illuminate\Support\Collection) { return collect($result)->map( @@ -560,30 +558,25 @@ function ($model) use ($request) { ); } + if (!$result instanceof \Illuminate\Database\Eloquent\Model) { + return $result; + } + + $with = RequestRelations::eagerLoads($result, $request->or(['with', 'expand'], [])); + $without = array_filter($request->array('without'), 'is_string'); + if ($with) { $result->load($with); } + // makeHidden adds to the model's hidden attributes; setHidden would replace them and expose the defaults if ($without) { - $result->setHidden($without); + $result->makeHidden($without); } return $result; } - protected static function normalizeRequestRelations($relations) - { - if (empty($relations)) { - return []; - } - - $relations = is_array($relations) ? $relations : explode(',', $relations); - - return array_map(function ($relation) { - return implode('.', array_map(fn ($part) => Str::camel(trim($part)), explode('.', $relation))); - }, $relations); - } - /** * Fills the target array with session attributes based on the specified rules. * Allows to apply exceptions and only specific attributes to be filled. @@ -640,33 +633,9 @@ public function withRelationships(Request $request, $builder) return $builder; } - $contains = is_array($with) ? $with : explode(',', $with); - - foreach ($contains as $contain) { - $camelVersion = Str::camel(trim($contain)); - if (\method_exists($this, $camelVersion)) { - $builder->with($camelVersion); - continue; - } - - $snakeCase = Str::snake(trim($contain)); - if (\method_exists($this, $snakeCase)) { - $builder->with(trim($snakeCase)); - continue; - } - - if (strpos($contain, '.') !== false) { - $parts = array_map( - function ($part) { - return Str::camel($part); - }, - explode('.', $contain) - ); - $contain = implode('.', $parts); - - $builder->with($contain); - continue; - } + $contains = RequestRelations::eagerLoads($this, $with); + if ($contains) { + $builder->with($contains); } if ($without) { @@ -692,19 +661,9 @@ public function withCounts($request, $builder) return $builder; } - $counters = explode(',', $count); - - foreach ($counters as $counter) { - if (\method_exists($this, $counter)) { - $builder->withCount($counter); - continue; - } - - $camelVersion = Str::camel($counter); - if (\method_exists($this, $camelVersion)) { - $builder->withCount($camelVersion); - continue; - } + $counters = RequestRelations::counts($this, $count); + if ($counters) { + $builder->withCount($counters); } return $builder; @@ -965,16 +924,9 @@ public function applyCustomFilters(Request $request, $builder) $without = $request->array('without'); $withoutRelations = $request->boolean('without_relations'); - // camelcase all params in with and apply + // apply only the requested names that are relations of this model if (is_array($with)) { - $with = array_map( - function ($relationship) { - return Str::camel($relationship); - }, - $with - ); - - $builder = $builder->with($with); + $builder = $builder->with(RequestRelations::eagerLoads($this, $with)); } // camelcase all params in with and apply diff --git a/tests/Unit/Models/TemplateModelsTest.php b/tests/Unit/Models/TemplateModelsTest.php index d0a221fb..1c3a940e 100644 --- a/tests/Unit/Models/TemplateModelsTest.php +++ b/tests/Unit/Models/TemplateModelsTest.php @@ -5,6 +5,7 @@ use Fleetbase\Traits\Searchable; use Illuminate\Database\Capsule\Manager as Capsule; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Events\Dispatcher; use Illuminate\Support\Facades\Facade; @@ -15,10 +16,19 @@ class TemplateQueryTenantFixture extends Model protected $guarded = []; public $timestamps = false; - public function notes() + public static int $archived = 0; + + public function notes(): HasMany { return $this->hasMany(TemplateQueryTenantNoteFixture::class, 'fixture_id'); } + + public function archive() + { + static::$archived++; + + return $this; + } } class TemplateQueryTenantNoteFixture extends Model @@ -411,16 +421,18 @@ function template_models_database(): Capsule ['field' => 'archived_at', 'operator' => 'null'], ['field' => null, 'operator' => '=', 'value' => 'ignored'], ], - 'with' => ['notes'], + 'with' => ['notes', 'archive'], 'sort' => [ ['field' => 'score', 'direction' => 'desc'], ], 'limit' => 1, ]); - $results = $query->execute(); + TemplateQueryTenantFixture::$archived = 0; + $results = $query->execute(); expect($results)->toHaveCount(1) + ->and(TemplateQueryTenantFixture::$archived)->toBe(0) ->and($results->first()->name)->toBe('Alpha order') ->and($results->first()->company_uuid)->toBe('company-1') ->and($results->first()->relationLoaded('notes'))->toBeTrue() diff --git a/tests/Unit/Support/RequestRelationsTest.php b/tests/Unit/Support/RequestRelationsTest.php new file mode 100644 index 00000000..c942a0d4 --- /dev/null +++ b/tests/Unit/Support/RequestRelationsTest.php @@ -0,0 +1,204 @@ +hasMany(RequestRelationsChild::class, 'parent_id'); + } + + public function nullableOwner(): ?BelongsTo + { + return $this->belongsTo(RequestRelationsChild::class, 'owner_id'); + } + + public function scopedOwner(): BelongsTo|Builder + { + return $this->belongsTo(RequestRelationsChild::class, 'owner_id'); + } + + public function subject(): MorphTo + { + return $this->morphTo(); + } + + public function untypedChildren() + { + return $this->hasMany(RequestRelationsChild::class, 'parent_id'); + } + + public function legacyChildren() + { + return $this->hasMany(RequestRelationsChild::class, 'parent_id'); + } + + public function dispatch(bool $save = true) + { + static::$calls++; + + return $this; + } + + public function typedChildrenFor(string $type): HasMany + { + return $this->hasMany(RequestRelationsChild::class, 'parent_id')->where('type', $type); + } + + public static function staticChildren(): HasMany + { + return (new static())->hasMany(RequestRelationsChild::class, 'parent_id'); + } + + protected function hiddenChildren(): HasMany + { + return $this->hasMany(RequestRelationsChild::class, 'parent_id'); + } +} + +class RequestRelationsChild extends EloquentModel +{ + public static int $calls = 0; + + protected $table = 'request_relations_children'; + + public function parent(): BelongsTo + { + return $this->belongsTo(RequestRelationsParent::class, 'parent_id'); + } + + public function untypedParent() + { + return $this->belongsTo(RequestRelationsParent::class, 'parent_id'); + } + + public function cancel() + { + static::$calls++; + + return $this; + } +} + +class RequestRelationsExpansion +{ + public static function target(): string + { + return RequestRelationsParent::class; + } + + public static function expandedChildren() + { + return function (): HasMany { + /* @var RequestRelationsParent $this */ + return $this->hasMany(RequestRelationsChild::class, 'parent_id'); + }; + } + + public static function untypedExpandedChildren() + { + return function () { + /* @var RequestRelationsParent $this */ + return $this->hasMany(RequestRelationsChild::class, 'parent_id'); + }; + } +} + +beforeEach(function () { + bind_test_container(); + + $capsule = new Capsule(app()); + $capsule->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + + RequestRelationsParent::expand(new RequestRelationsExpansion()); + RequestRelationsParent::$calls = 0; + RequestRelationsChild::$calls = 0; +}); + +afterEach(function () { + EloquentModel::unsetConnectionResolver(); + EloquentModel::clearBootedModels(); +}); + +test('request relations accept methods that declare a relation return type', function () { + $model = new RequestRelationsParent(); + + expect(RequestRelations::eagerLoads($model, ['children', 'nullable_owner', 'scopedOwner', 'subject'])) + ->toBe(['children', 'nullableOwner', 'scopedOwner', 'subject']) + ->and(RequestRelations::eagerLoads($model, 'children, nullable_owner'))->toBe(['children', 'nullableOwner']) + ->and(RequestRelations::eagerLoads($model, ['nullableowner', 'legacychildren']))->toBe(['nullableOwner', 'legacyChildren']) + ->and(RequestRelations::counts($model, ['children', 'scoped_owner']))->toBe(['children', 'scopedOwner']); +}); + +test('request relations drop untyped methods unless the model lists them as withable', function () { + $model = new RequestRelationsParent(); + + expect(RequestRelations::eagerLoads($model, ['untypedChildren', 'legacy_children']))->toBe(['legacyChildren']) + ->and(RequestRelations::counts($model, ['untypedChildren', 'legacyChildren']))->toBe(['legacyChildren']); +}); + +test('request relations never call a method that is not a relation', function () { + $model = new RequestRelationsParent(); + $requested = ['dispatch', 'save', 'push', 'touch', 'delete', 'fresh', 'replicate', 'toArray', 'getKey', 'children.cancel', 'children.parent.dispatch']; + + expect(RequestRelations::eagerLoads($model, $requested))->toBe(['children', 'children.parent']) + ->and(RequestRelations::counts($model, $requested))->toBe([]) + ->and(RequestRelationsParent::$calls)->toBe(0) + ->and(RequestRelationsChild::$calls)->toBe(0); +}); + +test('request relations drop static protected and argument taking methods', function () { + expect(RequestRelations::eagerLoads(new RequestRelationsParent(), ['staticChildren', 'hiddenChildren', 'typedChildrenFor']))->toBe([]); +}); + +test('request relations check each nested segment against the related model', function () { + $model = new RequestRelationsParent(); + + expect(RequestRelations::eagerLoads($model, ['children.parent.children', 'children.untyped_parent', 'children.missing.parent'])) + ->toBe(['children.parent.children', 'children']) + ->and(RequestRelations::eagerLoads($model, ['children.parent.children.parent.children.parent.children'])) + ->toBe(['children.parent.children.parent.children']) + ->and(RequestRelations::counts($model, ['children.parent']))->toBe([]); +}); + +test('request relations stop a path at a morph to and never count one', function () { + $model = new RequestRelationsParent(); + + expect(RequestRelations::eagerLoads($model, ['subject.dispatch', 'subject.children']))->toBe(['subject']) + ->and(RequestRelations::counts($model, ['subject']))->toBe([]); +}); + +test('request relations accept expansion closures by the same rule', function () { + $model = new RequestRelationsParent(); + + expect(RequestRelations::eagerLoads($model, ['expandedChildren', 'untypedExpandedChildren', 'expanded_children.parent'])) + ->toBe(['expandedChildren', 'expandedChildren.parent']); +}); + +test('request relations ignore malformed input and cap the number of paths', function () { + $model = new RequestRelationsParent(); + + expect(RequestRelations::eagerLoads($model, ['children:id,name', 'children items', '', ' ', ['children'], 42, null, 'children..parent'])) + ->toBe(['children']) + ->and(RequestRelations::eagerLoads($model, null))->toBe([]) + ->and(RequestRelations::eagerLoads($model, 42))->toBe([]) + ->and(RequestRelations::eagerLoads($model, array_merge(array_map(fn ($i) => "missing{$i}", range(1, RequestRelations::MAX_PATHS)), ['children'])))->toBe([]); +}); diff --git a/tests/Unit/Traits/HasApiModelBehaviorTest.php b/tests/Unit/Traits/HasApiModelBehaviorTest.php index b6fd5865..9c2831b2 100644 --- a/tests/Unit/Traits/HasApiModelBehaviorTest.php +++ b/tests/Unit/Traits/HasApiModelBehaviorTest.php @@ -8,6 +8,7 @@ use Illuminate\Database\Capsule\Manager as Capsule; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Database\Eloquent\Model as EloquentModel; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Events\Dispatcher; use Illuminate\Http\JsonResponse; @@ -172,7 +173,7 @@ class HasApiModelBehaviorRecord extends Model 'updated_at', ]; - public function childItems() + public function childItems(): HasMany { return $this->hasMany(HasApiModelBehaviorChild::class, 'record_uuid', 'uuid'); } @@ -348,7 +349,7 @@ class HasApiModelBehaviorSnakeRelationRecord extends Model 'name', ]; - public function child_items() + public function child_items(): HasMany { return $this->hasMany(HasApiModelBehaviorChild::class, 'record_uuid', 'uuid'); } @@ -363,6 +364,11 @@ class HasApiModelBehaviorChild extends Model public $timestamps = false; } +class HasApiModelBehaviorHiddenRecord extends HasApiModelBehaviorRecord +{ + protected $hidden = ['amount']; +} + /** * Mirrors fleetops' Order::dispatch(): a public method with no required arguments * that writes a row and would fire events. A request naming it must never run it. @@ -1059,12 +1065,25 @@ public function total(): int ->and($nullStatuses)->toBe(['record-3']) ->and($notNullStatuses)->toBe(['record-1', 'record-2']) ->and($likeNames)->toBe(['record-1']) - ->and(array_keys($relationshipBuilder->getEagerLoads()))->toBe(['childItems', 'childItems.grandChildren']) + ->and(array_keys($relationshipBuilder->getEagerLoads()))->toBe(['childItems']) ->and($relationshipBuilder->getQuery()->columns)->toBeNull() ->and(array_keys($snakeRelationBuilder->getEagerLoads()))->toBe(['child_items']) ->and($countBuilder->toSql())->toContain('api_model_behavior_children', 'child_items_count'); }); +test('api model behavior without adds to the hidden attributes instead of replacing them', function () { + $capsule = has_api_model_behavior_database(); + has_api_model_behavior_seed_records($capsule); + + $results = (new HasApiModelBehaviorHiddenRecord())->queryFromRequest(has_api_model_behavior_request([ + 'company_uuid' => 'company-a', + 'without' => ['slug'], + ])); + + expect($results->first()->getHidden())->toContain('amount', 'slug') + ->and($results->first()->toArray())->not->toHaveKeys(['amount', 'slug']); +}); + test('api model behavior never calls a requested method that is not a relation', function (array $input) { $capsule = has_api_model_behavior_database(); has_api_model_behavior_seed_records($capsule); From d8a9ac8d94c38dad4599d9e7c33f962709d0df2a Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 13:23:55 +0800 Subject: [PATCH 35/51] test(notifications): saved settings cannot name an unregistered dynamic property or class A subject with a side-effect dispatch() method is named as dynamic:dispatch, directly and through a resolveDynamicNotifiable() that loads the property like fleetops Order does, and a class whose constructor records a call is named as a definition. The existing dynamic and class tests now register what they use. --- .../Unit/Support/NotificationRegistryTest.php | 150 +++++++++++++++++- 1 file changed, 149 insertions(+), 1 deletion(-) diff --git a/tests/Unit/Support/NotificationRegistryTest.php b/tests/Unit/Support/NotificationRegistryTest.php index 4bfd0d96..f83d9c80 100644 --- a/tests/Unit/Support/NotificationRegistryTest.php +++ b/tests/Unit/Support/NotificationRegistryTest.php @@ -116,6 +116,47 @@ class NotificationRegistryDispatchPropertySubject extends EloquentModel public $timestamps = false; } +class NotificationRegistrySideEffectSubject extends EloquentModel +{ + public static array $calls = []; + + protected $primaryKey = 'uuid'; + + public $incrementing = false; + + protected $keyType = 'string'; + + public $timestamps = false; + + public function dispatch(): void + { + static::$calls[] = $this->uuid ?? 'new-instance'; + } +} + +class NotificationRegistryLoadingSubject extends NotificationRegistrySideEffectSubject +{ + // Mirrors fleetops Order::resolveDynamicNotifiable(), which loads any property it is given + public function resolveDynamicNotifiable(string $property): mixed + { + if (!$this->relationLoaded($property)) { + $this->load($property); + } + + return $this->{$property}; + } +} + +class NotificationRegistryConstructorProbe +{ + public static int $constructed = 0; + + public function __construct() + { + static::$constructed++; + } +} + class NotificationRegistryDispatchCacheFake { private array $values = []; @@ -218,8 +259,12 @@ function notification_registry_dispatch_database(): Capsule Fleetbase\Models\Group::class, Fleetbase\Models\Role::class, Fleetbase\Models\Company::class, + NotificationRegistryDispatchTarget::class, + NotificationRegistryDispatchGroup::class, ]; - NotificationRegistryDispatchTarget::$sent = []; + NotificationRegistryDispatchTarget::$sent = []; + NotificationRegistrySideEffectSubject::$calls = []; + NotificationRegistryConstructorProbe::$constructed = 0; }); afterEach(function () { @@ -321,6 +366,7 @@ function notification_registry_dispatch_database(): Capsule session(['company' => 'company-1']); NotificationRegistry::register(NotificationRegistryDispatchNotification::class); + NotificationRegistry::registerNotifiable('dynamic:assignee'); Fleetbase\Models\Setting::query()->create([ 'key' => 'company.company-1.notification_settings', @@ -379,6 +425,7 @@ function notification_registry_dispatch_database(): Capsule test('notification registry dispatches by definition name with dynamic subject context', function () { notification_registry_dispatch_database(); + NotificationRegistry::registerNotifiable('dynamic:dispatcher'); Fleetbase\Models\Setting::query()->create([ 'key' => 'company.company-1.notification_settings', @@ -505,6 +552,7 @@ function notification_registry_dispatch_database(): Capsule test('notification registry resolves dynamic notifiables from subject properties', function () { notification_registry_dispatch_database(); + NotificationRegistry::registerNotifiable('dynamic:assignee'); Fleetbase\Models\Setting::query()->create([ 'key' => 'company.company-1.notification_settings', @@ -565,6 +613,106 @@ function notification_registry_dispatch_database(): Capsule expect(NotificationRegistryDispatchTarget::$sent)->toBe([]); }); +function notification_registry_settings_with(array $notifiables): void +{ + Fleetbase\Models\Setting::query()->create([ + 'key' => 'company.company-1.notification_settings', + 'value' => [ + 'notificationRegistryDispatchNotification__manualNotice' => [ + 'notifiables' => [ + ...$notifiables, + [ + 'definition' => NotificationRegistryDispatchTarget::class, + 'primaryKey' => 'uuid', + 'key' => 'target-2', + ], + ], + ], + ], + ]); +} + +test('notification registry recognises only registered notifiable definitions', function () { + NotificationRegistry::registerNotifiable('dynamic:driver'); + + expect(NotificationRegistry::isRegisteredNotifiable('dynamic:driver'))->toBeTrue() + ->and(NotificationRegistry::isRegisteredNotifiable(Fleetbase\Models\User::class))->toBeTrue() + ->and(NotificationRegistry::isRegisteredNotifiable('\\' . Fleetbase\Models\User::class))->toBeTrue() + ->and(NotificationRegistry::isRegisteredNotifiable('dynamic:dispatch'))->toBeFalse() + ->and(NotificationRegistry::isRegisteredNotifiable('dynamic:Driver'))->toBeFalse() + ->and(NotificationRegistry::isRegisteredNotifiable(NotificationRegistryConstructorProbe::class))->toBeFalse() + ->and(NotificationRegistry::isRegisteredNotifiable(''))->toBeFalse() + ->and(NotificationRegistry::isRegisteredNotifiable(null))->toBeFalse() + ->and(NotificationRegistry::isRegisteredNotifiable(['dynamic:driver']))->toBeFalse(); +}); + +test('notification registry does not read an unregistered dynamic property from the subject', function () { + notification_registry_dispatch_database(); + notification_registry_settings_with([ + ['definition' => 'dynamic:dispatch', 'primaryKey' => 'uuid', 'key' => 'ignored-for-dynamic'], + ]); + + $subject = new NotificationRegistrySideEffectSubject(); + $subject->uuid = 'subject-side-effect'; + $subject->company_uuid = 'company-1'; + + NotificationRegistry::notifyUsingDefinitionName(NotificationRegistryDispatchNotification::class, 'Manual Notice', $subject, 'skipped'); + + expect(NotificationRegistrySideEffectSubject::$calls)->toBe([]) + ->and(array_column(NotificationRegistryDispatchTarget::$sent, 'target'))->toBe(['target-2']); +}); + +test('notification registry does not pass an unregistered dynamic property to the subject resolver', function () { + notification_registry_dispatch_database(); + notification_registry_settings_with([ + ['definition' => 'dynamic:dispatch', 'primaryKey' => 'uuid', 'key' => 'ignored-for-dynamic'], + ]); + + $subject = new NotificationRegistryLoadingSubject(); + $subject->uuid = 'subject-loading'; + $subject->company_uuid = 'company-1'; + + NotificationRegistry::notifyUsingDefinitionName(NotificationRegistryDispatchNotification::class, 'Manual Notice', $subject, 'skipped'); + + expect(NotificationRegistrySideEffectSubject::$calls)->toBe([]) + ->and(array_column(NotificationRegistryDispatchTarget::$sent, 'target'))->toBe(['target-2']); +}); + +test('notification registry still resolves a registered dynamic property through the subject resolver', function () { + notification_registry_dispatch_database(); + NotificationRegistry::registerNotifiable('dynamic:dispatcher'); + notification_registry_settings_with([ + ['definition' => 'dynamic:dispatcher', 'primaryKey' => 'uuid', 'key' => 'ignored-for-dynamic'], + ]); + + $subject = new NotificationRegistryDispatchSubject(); + $subject->uuid = 'subject-registered'; + $subject->company_uuid = 'company-1'; + + NotificationRegistry::notifyUsingDefinitionName(NotificationRegistryDispatchNotification::class, 'Manual Notice', $subject, 'registered'); + + expect(array_column(NotificationRegistryDispatchTarget::$sent, 'target'))->toBe(['target-1', 'target-2']); +}); + +test('notification registry does not build an unregistered notifiable class', function () { + notification_registry_dispatch_database(); + NotificationRegistry::$notifiables = array_values(array_diff(NotificationRegistry::$notifiables, [NotificationRegistryDispatchGroup::class])); + notification_registry_settings_with([ + ['definition' => NotificationRegistryConstructorProbe::class, 'primaryKey' => 'uuid', 'key' => 'probe'], + ['definition' => NotificationRegistryDispatchGroup::class, 'primaryKey' => 'uuid', 'key' => 'group-1'], + ['definition' => ['not', 'a', 'class'], 'primaryKey' => 'uuid', 'key' => 'array'], + ]); + + $subject = new NotificationRegistryDispatchSubject(); + $subject->uuid = 'subject-class'; + $subject->company_uuid = 'company-1'; + + NotificationRegistry::notifyUsingDefinitionName(NotificationRegistryDispatchNotification::class, 'Manual Notice', $subject, 'class'); + + expect(NotificationRegistryConstructorProbe::$constructed)->toBe(0) + ->and(array_column(NotificationRegistryDispatchTarget::$sent, 'target'))->toBe(['target-2']); +}); + function notification_registry_company_settings(string $companyUuid, string $settingsKey, string $targetUuid = 'target-1'): void { Fleetbase\Models\Setting::query()->create([ From 4b39d4cfde5d23449ab17baa75e5504c6e79f7d4 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 13:23:55 +0800 Subject: [PATCH 36/51] fix(notifications): resolve only registered notifiables from saved settings resolveNotifiable() read the definition from the company's notification_settings, which saveSettings stores as given. dynamic: reached any public no-argument method on the subject, through resolveDynamicNotifiable() or the Eloquent relation getter, so dynamic:dispatch ran Order::dispatch() whenever a notification fired; a class definition was built with app(). Definitions not registered with registerNotifiable() are now skipped. --- CHANGELOG.md | 3 +++ src/Support/NotificationRegistry.php | 30 ++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index db652f5e..ff2784b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,3 +19,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Removed - Legacy duck-typed transformers (`$target` property + static `output($model, $data)`), `ResourceTransformerRegistry::transform(Model, array)`, `resolveByTarget()`, `fixClassName()` and the static `$transformers` array. The `User` resource no longer calls the registry directly. - Adds first version + +### Security +- `NotificationRegistry` only resolves notifiables registered with `registerNotifiable()`. A company's saved notification settings could name any `dynamic:`, which reached any public no-argument method on the notification subject (for example fleetops `Order::dispatch()`), or any class name, which the container built. Unregistered definitions are now skipped; extensions must register every `dynamic:` name and notifiable class they expect to receive. diff --git a/src/Support/NotificationRegistry.php b/src/Support/NotificationRegistry.php index 2714dfe0..f8c96902 100644 --- a/src/Support/NotificationRegistry.php +++ b/src/Support/NotificationRegistry.php @@ -391,9 +391,35 @@ protected static function resolveNotificationSettings(...$params): mixed return Setting::lookupForCompany($companyUuid ?? session('company'), 'notification_settings', []); } + /** + * Determine whether a notifiable definition was registered with `registerNotifiable()`. + * + * @param mixed $definition a notifiable class name or a `dynamic:` name + */ + public static function isRegisteredNotifiable($definition): bool + { + if (!is_string($definition) || $definition === '') { + return false; + } + + $definition = ltrim($definition, '\\'); + foreach (static::$notifiables as $notifiable) { + if (is_string($notifiable) && ltrim($notifiable, '\\') === $definition) { + return true; + } + } + + return false; + } + /** * Resolve a notifiable object to an Eloquent model. * + * The notifiable object comes from the company's saved notification settings, so only registered + * notifiables are resolved. An unregistered `dynamic:` would reach any public method on the + * subject that takes no arguments, through `resolveDynamicNotifiable()` or Eloquent's relation getter, + * and an unregistered class would be built by the container. Anything else is skipped. + * * @param array $notifiableObject an associative array containing the definition and primary key to resolve the notifiable object * * @return Model|null the Eloquent model or null if it cannot be resolved @@ -404,6 +430,10 @@ protected static function resolveNotifiable(array $notifiableObject, $subject = $primaryKey = data_get($notifiableObject, 'primaryKey'); $key = data_get($notifiableObject, 'key'); + if (!static::isRegisteredNotifiable($definition)) { + return null; + } + // Handle dynamic subject resolution if (Str::startsWith($definition, 'dynamic:')) { $property = str_replace('dynamic:', '', $definition); From 56f942086375bb432f0587417ee20363a6aed3b1 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 13:41:36 +0800 Subject: [PATCH 37/51] test(api): cover every line of the relation guard The coverage gate needs 100% per file. A typed relation that throws on a blank model is now tested (it is dropped), as is mutateModelWithRequest() on a non-model. The Expandable branch loses a Closure check that could never fail: isExpansion() only holds for a registered Closure. --- src/Support/RequestRelations.php | 8 ++------ tests/Unit/Support/RequestRelationsTest.php | 10 ++++++++++ tests/Unit/Traits/HasApiModelBehaviorTest.php | 3 ++- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/Support/RequestRelations.php b/src/Support/RequestRelations.php index 4c62344c..dce3f2ca 100644 --- a/src/Support/RequestRelations.php +++ b/src/Support/RequestRelations.php @@ -152,13 +152,9 @@ protected static function callable(Model $model, string $name): ?\ReflectionFunc return $method->isPublic() && !$method->isStatic() && $method->getNumberOfRequiredParameters() === 0 ? $method : null; } + // isExpansion() only holds for a registered Closure if (method_exists($model, 'isExpansion') && $model::isExpansion($name)) { - $closure = $model::getExpansionClosure($name); - if (!$closure instanceof \Closure) { - return null; - } - - $closure = new \ReflectionFunction($closure); + $closure = new \ReflectionFunction($model::getExpansionClosure($name)); return $closure->getNumberOfRequiredParameters() === 0 ? $closure : null; } diff --git a/tests/Unit/Support/RequestRelationsTest.php b/tests/Unit/Support/RequestRelationsTest.php index c942a0d4..f0c817bc 100644 --- a/tests/Unit/Support/RequestRelationsTest.php +++ b/tests/Unit/Support/RequestRelationsTest.php @@ -39,6 +39,11 @@ public function subject(): MorphTo return $this->morphTo(); } + public function brokenChildren(): HasMany + { + throw new RuntimeException('relation cannot be built on a blank model'); + } + public function untypedChildren() { return $this->hasMany(RequestRelationsChild::class, 'parent_id'); @@ -169,6 +174,11 @@ public static function untypedExpandedChildren() expect(RequestRelations::eagerLoads(new RequestRelationsParent(), ['staticChildren', 'hiddenChildren', 'typedChildrenFor']))->toBe([]); }); +test('request relations drop a typed relation that cannot be built instead of failing the request', function () { + expect(RequestRelations::eagerLoads(new RequestRelationsParent(), ['brokenChildren', 'children']))->toBe(['children']) + ->and(RequestRelations::counts(new RequestRelationsParent(), ['brokenChildren']))->toBe([]); +}); + test('request relations check each nested segment against the related model', function () { $model = new RequestRelationsParent(); diff --git a/tests/Unit/Traits/HasApiModelBehaviorTest.php b/tests/Unit/Traits/HasApiModelBehaviorTest.php index 9c2831b2..a8dcba73 100644 --- a/tests/Unit/Traits/HasApiModelBehaviorTest.php +++ b/tests/Unit/Traits/HasApiModelBehaviorTest.php @@ -1081,7 +1081,8 @@ public function total(): int ])); expect($results->first()->getHidden())->toContain('amount', 'slug') - ->and($results->first()->toArray())->not->toHaveKeys(['amount', 'slug']); + ->and($results->first()->toArray())->not->toHaveKeys(['amount', 'slug']) + ->and(HasApiModelBehaviorHiddenRecord::mutateModelWithRequest(has_api_model_behavior_request(['with' => 'childItems']), null))->toBeNull(); }); test('api model behavior never calls a requested method that is not a relation', function (array $input) { From 21c959ded084b089b3ddcebbc8d54ff9f765d064 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 13:44:50 +0800 Subject: [PATCH 38/51] test(api-cache): cover the session scope of a request without a session getSessionScope returns no scope when the request has no session. Every test request had one, so the release branch fell one statement short of the coverage floor after #300. --- tests/Unit/Support/ApiModelCacheTest.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/Unit/Support/ApiModelCacheTest.php b/tests/Unit/Support/ApiModelCacheTest.php index 98978d5b..9450b993 100644 --- a/tests/Unit/Support/ApiModelCacheTest.php +++ b/tests/Unit/Support/ApiModelCacheTest.php @@ -742,6 +742,24 @@ public function gc(int $max_lifetime): int|false ->and($key(['customer' => 'customer-a', 'storefront_store' => 'store-a']))->toBe($key(['customer' => 'customer-a', 'storefront_store' => 'store-b'])); }); +test('api model cache leaves requests without a session unscoped', function () { + api_model_cache_fixture(); + + $sessionScope = new ReflectionMethod(ApiModelCache::class, 'getSessionScope'); + $sessionScope->setAccessible(true); + + expect($sessionScope->invoke(null, Request::create('/v1/orders', 'GET')))->toBe([]) + ->and($sessionScope->invoke(null, api_model_cache_scoped_request('/v1/orders', [ + 'company' => 'company-1', + 'storefront_store' => 'store-1', + 'api_credential' => 'credential-1', + 'storefront_network' => null, + ])))->toBe([ + 'api_credential' => 'credential-1', + 'storefront_store' => 'store-1', + ]); +}); + test('api model cache resolves tenant scope from authenticated user and request input fallbacks', function () { api_model_cache_fixture(); $model = api_model_cache_model(); From e159bfd90aec2b5c6c5174ca8dfd0d4738ae8a90 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 13:46:40 +0800 Subject: [PATCH 39/51] fix(api-cache): build list cache keys for requests without a session getCompanyUuid called $request->session() unguarded, so generateQueryCacheKey threw "Session store not set on request." before getSessionScope's hasSession check could apply. Check hasSession first and fall back to the user and request input as before. The test now covers the sessionless path through generateQueryCacheKey instead of calling getSessionScope by reflection. --- src/Support/ApiModelCache.php | 2 +- tests/Unit/Support/ApiModelCacheTest.php | 27 ++++++++++++------------ 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/Support/ApiModelCache.php b/src/Support/ApiModelCache.php index 2e1b99c9..e4a95684 100644 --- a/src/Support/ApiModelCache.php +++ b/src/Support/ApiModelCache.php @@ -496,7 +496,7 @@ public static function getRelationshipTtl(): int protected static function getCompanyUuid(Request $request): ?string { // Try to get from session - if ($request->session()->has('company')) { + if ($request->hasSession() && $request->session()->has('company')) { return $request->session()->get('company'); } diff --git a/tests/Unit/Support/ApiModelCacheTest.php b/tests/Unit/Support/ApiModelCacheTest.php index 9450b993..0362d446 100644 --- a/tests/Unit/Support/ApiModelCacheTest.php +++ b/tests/Unit/Support/ApiModelCacheTest.php @@ -742,22 +742,21 @@ public function gc(int $max_lifetime): int|false ->and($key(['customer' => 'customer-a', 'storefront_store' => 'store-a']))->toBe($key(['customer' => 'customer-a', 'storefront_store' => 'store-b'])); }); -test('api model cache leaves requests without a session unscoped', function () { +test('api model cache keys requests without a session by company input and leaves them unscoped', function () { api_model_cache_fixture(); + $model = api_model_cache_model(); - $sessionScope = new ReflectionMethod(ApiModelCache::class, 'getSessionScope'); - $sessionScope->setAccessible(true); - - expect($sessionScope->invoke(null, Request::create('/v1/orders', 'GET')))->toBe([]) - ->and($sessionScope->invoke(null, api_model_cache_scoped_request('/v1/orders', [ - 'company' => 'company-1', - 'storefront_store' => 'store-1', - 'api_credential' => 'credential-1', - 'storefront_network' => null, - ])))->toBe([ - 'api_credential' => 'credential-1', - 'storefront_store' => 'store-1', - ]); + $sessionless = ApiModelCache::generateQueryCacheKey($model, Request::create('/v1/orders', 'GET', [ + 'status' => 'active', + 'company_uuid' => 'company-1', + ])); + $unscoped = ApiModelCache::generateQueryCacheKey($model, api_model_cache_scoped_request('/v1/orders', [], [ + 'status' => 'active', + 'company_uuid' => 'company-1', + ])); + + expect($sessionless)->toStartWith('{api_query}:orders:company_company-1:v0:') + ->and($sessionless)->toBe($unscoped); }); test('api model cache resolves tenant scope from authenticated user and request input fallbacks', function () { From 08be0b8d24ea343ed0c502ec3ca192cb1bab95ae Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 14:00:31 +0800 Subject: [PATCH 40/51] refactor(api-logs): log any route that opts in to LogApiRequests and let extensions relate ids Core no longer names storefront. Every route that runs LogApiRequests opted in through its middleware group (core's public API or an extension's), so shouldLog only skips internal routes and requests without a route, instead of requiring the core v1/ prefix. isPublicRequest is back to v1/ only, so storefront filters and resources are unchanged. LogApiRequest::RELATED_ATTRIBUTE ("api_request_log_related") is a request attribute whose ids are added to the log's related column. Extensions set it from their middleware, for example storefront with the public id of the store or network whose key made the request. --- src/Http/Middleware/LogApiRequests.php | 5 ++- src/Jobs/LogApiRequest.php | 19 ++++++-- src/Support/Http.php | 5 +-- tests/Unit/Http/MiddlewareContractsTest.php | 22 ++++++---- tests/Unit/Jobs/LogApiRequestTest.php | 43 +++++++------------ .../Support/TemplateStringAndHttpTest.php | 3 -- 6 files changed, 50 insertions(+), 47 deletions(-) diff --git a/src/Http/Middleware/LogApiRequests.php b/src/Http/Middleware/LogApiRequests.php index 7405ecf7..9e2714e5 100644 --- a/src/Http/Middleware/LogApiRequests.php +++ b/src/Http/Middleware/LogApiRequests.php @@ -59,7 +59,10 @@ public function handle($request, \Closure $next) */ protected function shouldLog($request): bool { - if (!Http::isPublicRequest($request)) { + // Every route that runs this middleware opted in to logging through its + // middleware group (core's public API or an extension's), so only skip + // requests without a route and internal routes such as "int/v1/..." + if ($request->route() === null || Http::isInternalRequest($request)) { return false; } diff --git a/src/Jobs/LogApiRequest.php b/src/Jobs/LogApiRequest.php index 27322b0c..5049d13f 100644 --- a/src/Jobs/LogApiRequest.php +++ b/src/Jobs/LogApiRequest.php @@ -25,6 +25,16 @@ class LogApiRequest implements ShouldQueue use Queueable; use SerializesModels; + /** + * Request attribute holding ids to add to the log's related column. + * + * Extensions set it from their middleware so a logged request can be traced + * to the resource behind it, for example the owner of the key that made it. + * + * @var string + */ + public const RELATED_ATTRIBUTE = 'api_request_log_related'; + /** * The API Request/JsonResponse payload. * @@ -82,10 +92,11 @@ public static function getPayload(Request $request, $response): array $related[] = Utils::get($content, 'id'); } - // If a storefront key made the request, push the store or network public id to related - $storefrontPublicId = session('storefront_store_public_id') ?? session('storefront_network_public_id'); - if ($storefrontPublicId) { - $related[] = $storefrontPublicId; + // Push ids that middleware related to this request, such as the resource whose key made it + foreach ((array) $request->attributes->get(static::RELATED_ATTRIBUTE, []) as $id) { + if ($id) { + $related[] = $id; + } } // Get api credential from session diff --git a/src/Support/Http.php b/src/Support/Http.php index 485cc7cf..eded33da 100644 --- a/src/Support/Http.php +++ b/src/Support/Http.php @@ -49,9 +49,8 @@ public static function isPublicRequest(?Request $request = null): bool $uri = ltrim((string) $route->uri(), '/'); - // Only true public APIs: the core "v1/" API and the storefront "storefront/v1/" API. - // Internal routes such as "int/v1/..." and "storefront/int/v1/..." never match. - return Str::startsWith($uri, ['v1/', 'storefront/v1/']); + // Only log true public API: starts with "v1/" + return Str::startsWith($uri, 'v1/'); } /** diff --git a/tests/Unit/Http/MiddlewareContractsTest.php b/tests/Unit/Http/MiddlewareContractsTest.php index 69c6d47d..5da56557 100644 --- a/tests/Unit/Http/MiddlewareContractsTest.php +++ b/tests/Unit/Http/MiddlewareContractsTest.php @@ -668,12 +668,19 @@ function middleware_contracts_log_middleware(bool $enabled = true): LogApiReques middleware_contracts_mutation_request('/v1/drivers/driver_1/track', 'v1/drivers/{driver}/track'), fn () => new JsonResponse(['id' => 'track_1'], 201) ); + $unroutedRequest = middleware_contracts_mutation_request('/v1/orders', 'v1/orders'); + $unroutedRequest->setRouteResolver(fn () => null); + $unrouted = middleware_contracts_log_middleware()->handle( + $unroutedRequest, + fn () => new JsonResponse(['id' => 'order_4'], 201) + ); expect($disabled->getStatusCode())->toBe(201) ->and($read->getStatusCode())->toBe(204) ->and($internal->getStatusCode())->toBe(201) ->and($storefrontInternal->getStatusCode())->toBe(201) ->and($excluded->getStatusCode())->toBe(201) + ->and($unrouted->getStatusCode())->toBe(201) ->and($bus->jobs)->toBeEmpty(); }); @@ -717,15 +724,11 @@ function middleware_contracts_log_middleware(bool $enabled = true): LogApiReques ]); }); - test('log api requests dispatches storefront mutation request logs related to the store', function () { + test('log api requests dispatches extension mutation request logs with related ids from the request', function () { middleware_contracts_fixture(['api.version' => 'v1']); session([ - 'company' => 'company-1', - 'api_credential' => 'store_live_key', - 'storefront_store' => 'store-uuid-1', - 'storefront_store_public_id' => 'store_abc123', - 'storefront_network' => null, - 'storefront_network_public_id' => null, + 'company' => 'company-1', + 'api_credential' => 'store_live_key', ]); $bus = new MiddlewareContractsBusFake(); app()->instance('bus', $bus); @@ -733,8 +736,11 @@ function middleware_contracts_log_middleware(bool $enabled = true): LogApiReques Facade::clearResolvedInstance('bus'); Facade::clearResolvedInstance(BusDispatcher::class); + $request = middleware_contracts_mutation_request('/storefront/v1/customers/request-creation-code', 'storefront/v1/customers/request-creation-code'); + $request->attributes->set(Fleetbase\Jobs\LogApiRequest::RELATED_ATTRIBUTE, ['store_abc123']); + $response = middleware_contracts_log_middleware()->handle( - middleware_contracts_mutation_request('/storefront/v1/customers/request-creation-code', 'storefront/v1/customers/request-creation-code'), + $request, fn () => new JsonResponse(['status' => 'ok'], 200) ); diff --git a/tests/Unit/Jobs/LogApiRequestTest.php b/tests/Unit/Jobs/LogApiRequestTest.php index c7e4a37a..eb55e01b 100644 --- a/tests/Unit/Jobs/LogApiRequestTest.php +++ b/tests/Unit/Jobs/LogApiRequestTest.php @@ -224,41 +224,28 @@ function loggable_request(float $startedAt): Request ->and($payload)->not->toHaveKeys(['api_credential_uuid', 'access_token_id']); }); - it('relates storefront payloads to the store or network whose key made the request', function () { + it('adds ids that middleware related to the request to the payload', function () { bind_test_container(['api.version' => 'v1']); session([ - 'api_credential' => 'store_live_key', - 'company' => 'company-1', - 'storefront_store' => 'store-uuid-1', - 'storefront_store_public_id' => 'store_abc123', - 'storefront_network' => null, - 'storefront_network_public_id' => null, + 'api_credential' => 'store_live_key', + 'company' => 'company-1', ]); - $storePayload = LogApiRequest::getPayload( - loggable_request(microtime(true) - 0.01), - new JsonResponse(['id' => 'customer_1'], 201) - ); + $related = loggable_request(microtime(true) - 0.01); + $related->attributes->set(LogApiRequest::RELATED_ATTRIBUTE, ['store_abc123', null, '']); - session([ - 'api_credential' => 'network_live_key', - 'company' => 'company-2', - 'storefront_store' => null, - 'storefront_store_public_id' => null, - 'storefront_network' => 'network-uuid-1', - 'storefront_network_public_id' => 'network_xyz789', - ]); + $single = loggable_request(microtime(true) - 0.01); + $single->attributes->set(LogApiRequest::RELATED_ATTRIBUTE, 'network_xyz789'); - $networkPayload = LogApiRequest::getPayload( - loggable_request(microtime(true) - 0.01), - new JsonResponse(['status' => 'ok'], 200) - ); + $relatedPayload = LogApiRequest::getPayload($related, new JsonResponse(['id' => 'customer_1'], 201)); + $singlePayload = LogApiRequest::getPayload($single, new JsonResponse(['status' => 'ok'], 200)); + $plainPayload = LogApiRequest::getPayload(loggable_request(microtime(true) - 0.01), new JsonResponse(['status' => 'ok'], 200)); - expect($storePayload['related'])->toBe(['customer_1', 'store_abc123']) - ->and($storePayload['company_uuid'])->toBe('company-1') - ->and($storePayload)->not->toHaveKeys(['api_credential_uuid', 'access_token_id']) - ->and($networkPayload['related'])->toBe(['network_xyz789']) - ->and($networkPayload['company_uuid'])->toBe('company-2'); + expect($relatedPayload['related'])->toBe(['customer_1', 'store_abc123']) + ->and($relatedPayload['company_uuid'])->toBe('company-1') + ->and($relatedPayload)->not->toHaveKeys(['api_credential_uuid', 'access_token_id']) + ->and($singlePayload['related'])->toBe(['network_xyz789']) + ->and($plainPayload['related'])->toBe([]); }); it('persists API request logs on the selected connection and clears response cache', function () { diff --git a/tests/Unit/Support/TemplateStringAndHttpTest.php b/tests/Unit/Support/TemplateStringAndHttpTest.php index 3429e638..24cb6645 100644 --- a/tests/Unit/Support/TemplateStringAndHttpTest.php +++ b/tests/Unit/Support/TemplateStringAndHttpTest.php @@ -71,9 +71,6 @@ function routed_request(string $uri, array $action = []): Request ->and(Http::isInternalRequest(routed_request('v1/orders')))->toBeFalse() ->and(Http::isPublicRequest(routed_request('v1/orders')))->toBeTrue() ->and(Http::isPublicRequest(routed_request('int/v1/orders')))->toBeFalse() - ->and(Http::isPublicRequest(routed_request('storefront/v1/customers/request-creation-code')))->toBeTrue() - ->and(Http::isPublicRequest(routed_request('storefront/int/v1/orders')))->toBeFalse() - ->and(Http::isPublicRequest(routed_request('storefront/orders')))->toBeFalse() ->and(Http::isInternalRequest(Request::create('/v1/orders')))->toBeFalse() ->and(Http::isPublicRequest(Request::create('/v1/orders')))->toBeFalse() ->and(Http::useSort('-created_at'))->toBe(['created_at', 'desc']) From 304f95103cee1c9a5535078c8f7cc09fd26ce8de Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 14:00:31 +0800 Subject: [PATCH 41/51] test(api-cache): cover the dependency memo limit, uncallable relation methods and an unresolvable transactions manager #301 left three statements in ApiModelCache unrun, so the release branch is under the 100% coverage floor. --- tests/Unit/Support/ApiModelCacheTest.php | 55 +++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/tests/Unit/Support/ApiModelCacheTest.php b/tests/Unit/Support/ApiModelCacheTest.php index 2100752a..d302cab1 100644 --- a/tests/Unit/Support/ApiModelCacheTest.php +++ b/tests/Unit/Support/ApiModelCacheTest.php @@ -206,6 +206,24 @@ class ApiModelCachePartlyResolvableDependencyOrder extends ApiModelCacheRelation protected $apiCacheDependencies = ['tags', 'payload.customer']; } +class ApiModelCacheUncallableRelationsOrder extends ApiModelCacheRelationsOrder +{ + public static function staticPayload(): BelongsTo + { + return (new static())->belongsTo(ApiModelCacheDependencyPayload::class, 'payload_uuid', 'uuid'); + } + + public function payloadFor(string $key): BelongsTo + { + return $this->belongsTo(ApiModelCacheDependencyPayload::class, $key, 'uuid'); + } + + protected function hiddenPayload(): BelongsTo + { + return $this->belongsTo(ApiModelCacheDependencyPayload::class, 'payload_uuid', 'uuid'); + } +} + class ApiModelCacheTestLock { public function __construct(private ApiModelCacheTestStore $store) @@ -987,6 +1005,41 @@ public function gc(int $max_lifetime): int|false app()->forgetInstance('db.transactions'); }); +test('api model cache keeps the immediate bump when the transactions manager cannot be resolved', function () { + $capsule = api_model_cache_dependency_database(); + $store = app('cache'); + $store->flushRemovesValues = false; + $connection = $capsule->getConnection('mysql'); + app()->bind('db.transactions', fn () => throw new RuntimeException('transactions manager unavailable')); + + $connection->beginTransaction(); + ApiModelCache::invalidateModelCache(new ApiModelCacheDependencyPayload(), 'company-1'); + $connection->commit(); + + app()->offsetUnset('db.transactions'); + + expect($store->get('api_query_version:payloads:company-1'))->toBe(1); +}); + +test('api model cache only resolves public instance relation methods without required parameters', function () { + api_model_cache_dependency_database(); + $order = new ApiModelCacheUncallableRelationsOrder(); + + expect(ApiModelCache::dependencyTables($order, ['static_payload', 'payload_for', 'hidden_payload']))->toBe([]) + ->and(ApiModelCache::dependencyTables($order, ['payload']))->toBe(['payloads']); +}); + +test('api model cache clears the relation path memo once it reaches its limit', function () { + api_model_cache_dependency_database(); + $memo = new ReflectionProperty(ApiModelCache::class, 'dependencyTables'); + $limit = (new ReflectionClassConstant(ApiModelCache::class, 'DEPENDENCY_MEMO_LIMIT'))->getValue(); + $memo->setAccessible(true); + $memo->setValue(null, array_fill_keys(array_map(fn ($i) => "memo-{$i}", range(1, $limit)), [])); + + expect(ApiModelCache::dependencyTables(new ApiModelCacheRelationsOrder(), ['payload']))->toBe(['payloads']) + ->and($memo->getValue())->toHaveCount(1); +}); + test('api model cache resolves request style relation paths without side effects on the model', function () { api_model_cache_dependency_database(); $order = new ApiModelCacheRelationsOrder(); @@ -1195,7 +1248,7 @@ public function gc(int $max_lifetime): int|false 'company_uuid' => 'company-1', ])); - expect($sessionless)->toStartWith('{api_query}:orders:company_company-1:v0:') + expect($sessionless)->toStartWith('{api_query}:orders:company_company-1:v0.0:') ->and($sessionless)->toBe($unscoped); }); From af4e55aadf053a5758d310aaf405c2f1430772fa Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 16:57:07 +0800 Subject: [PATCH 42/51] feat(sms): guard every SMS send with destination validation, budgets and a circuit breaker Every SMS now passes Fleetbase\Support\Sms\SmsGuard before a provider is called, and for verification codes before the code row is written. The guard validates the destination with libphonenumber (valid, mobile, not a premium or satellite range), enforces a per-destination cooldown and caps, a per-IP layer, per-tenant and per-company budgets and a platform-wide bucket, then counts the send. A company that uses up its daily budget, hits its hourly budget twice, or texts a burst of distinct new numbers is paused automatically and the system admins are alerted (mail plus in-app). Trusted purposes (2FA, admin actions) skip tenant pauses and budgets so staff can still sign in. Limits, the enforce/monitor mode, pauses and alert recipients are a stored system setting (config/sms.php supplies defaults), changeable at runtime with the sms:mode, sms:pause and sms:unpause commands or a later admin page. A refused send throws SmsBlockedException with its own status (422 bad number, 429 limited with Retry-After, 423 paused); existing email fallbacks keep working. Allowed sends record who asked and from where on the code row (meta.trace) and in sms.sent / sms.blocked log entries. The onboarding resend route gets a per-address throttle in front of the guard. Test bootstraps keep the guard off unless a test turns it on. --- config/sms.php | 76 ++++ src/Console/Commands/SmsGuardMode.php | 59 +++ src/Console/Commands/SmsGuardPause.php | 67 +++ src/Console/Commands/SmsGuardUnpause.php | 31 ++ src/Exceptions/Handler.php | 10 +- src/Exceptions/SmsBlockedException.php | 78 ++++ .../Internal/v1/SettingController.php | 2 +- .../Internal/v1/UserController.php | 2 +- src/Models/VerificationCode.php | 35 +- src/Notifications/SmsGuardAlert.php | 100 ++++ src/Providers/CoreServiceProvider.php | 3 + src/Services/SmsService.php | 37 +- src/Support/Sms/PhoneValidator.php | 156 +++++++ src/Support/Sms/SmsBreaker.php | 292 ++++++++++++ src/Support/Sms/SmsContext.php | 197 ++++++++ src/Support/Sms/SmsGuard.php | 297 ++++++++++++ src/Support/Sms/SmsPolicy.php | 427 ++++++++++++++++++ src/Support/Sms/SmsReservation.php | 73 +++ src/routes.php | 4 +- tests/Pest.php | 6 + tests/Unit/CallProSmsServiceTest.php | 1 + tests/Unit/Console/SmsGuardCommandsTest.php | 143 ++++++ .../Unit/Models/VerificationCodeGuardTest.php | 184 ++++++++ tests/Unit/MultiProviderSmsServiceTest.php | 1 + tests/Unit/SmsServiceGuardTest.php | 144 ++++++ tests/Unit/Support/Sms/PhoneValidatorTest.php | 70 +++ tests/Unit/Support/Sms/SmsGuardTest.php | 358 +++++++++++++++ tests/Unit/Support/Sms/SmsPolicyTest.php | 255 +++++++++++ 28 files changed, 3096 insertions(+), 12 deletions(-) create mode 100644 src/Console/Commands/SmsGuardMode.php create mode 100644 src/Console/Commands/SmsGuardPause.php create mode 100644 src/Console/Commands/SmsGuardUnpause.php create mode 100644 src/Exceptions/SmsBlockedException.php create mode 100644 src/Notifications/SmsGuardAlert.php create mode 100644 src/Support/Sms/PhoneValidator.php create mode 100644 src/Support/Sms/SmsBreaker.php create mode 100644 src/Support/Sms/SmsContext.php create mode 100644 src/Support/Sms/SmsGuard.php create mode 100644 src/Support/Sms/SmsPolicy.php create mode 100644 src/Support/Sms/SmsReservation.php create mode 100644 tests/Unit/Console/SmsGuardCommandsTest.php create mode 100644 tests/Unit/Models/VerificationCodeGuardTest.php create mode 100644 tests/Unit/SmsServiceGuardTest.php create mode 100644 tests/Unit/Support/Sms/PhoneValidatorTest.php create mode 100644 tests/Unit/Support/Sms/SmsGuardTest.php create mode 100644 tests/Unit/Support/Sms/SmsPolicyTest.php diff --git a/config/sms.php b/config/sms.php index 3d6749cf..1e430a7c 100644 --- a/config/sms.php +++ b/config/sms.php @@ -135,4 +135,80 @@ ], ], ], + + /* + |-------------------------------------------------------------------------- + | Send Guard + |-------------------------------------------------------------------------- + | + | Every SMS passes through Fleetbase\Support\Sms\SmsGuard before a provider + | is called (and, for verification codes, before the code row is written). + | The guard validates the destination with libphonenumber, enforces + | per-destination cooldowns and caps, per-tenant and platform-wide budgets, + | and pauses a tenant automatically when it crosses its budget or texts a + | burst of new numbers. This is what bounds the damage of a leaked public + | storefront key: see docs/security/storefront-sms-abuse-proposal.md. + | + | Everything here is a DEFAULT. A system administrator can change any of it + | at runtime (the stored override lives in the `system.sms-guard` setting and + | applies within a minute), so a deployment is never needed to switch modes, + | raise a tenant's budget or pause one. + | + | mode: "enforce" blocks, "monitor" only logs what would have been blocked. + | limits: counted in sends. null means no limit for that window. + | company: the default per-tenant budget; an administrator adds exceptions + | per company (raise, lower, unlimited). + | tenant: a store or network inside a company; null inherits the company's. + | probation: companies younger than `days` get this smaller daily budget, + | so a self-signup farm cannot mint a fresh budget per account. + | platform: enforced for all tenants together, deliberately high relative + | to a single tenant's cap. The "trusted" bucket is for purposes + | that already proved a password (2FA, admin actions). + | + */ + + 'guard' => [ + 'enabled' => env('SMS_GUARD_ENABLED', true), + 'mode' => env('SMS_GUARD_MODE', 'enforce'), + 'cache_store' => env('SMS_GUARD_CACHE_STORE'), + 'alert_emails' => env('SMS_GUARD_ALERT_EMAILS', ''), + + 'validation' => [ + 'enabled' => env('SMS_GUARD_VALIDATE', true), + 'allowed_types' => ['MOBILE', 'FIXED_LINE_OR_MOBILE'], + 'blocked_countries' => env('SMS_GUARD_BLOCKED_COUNTRIES', ''), + // International freephone, shared-cost, satellite, personal-number and + // premium ranges: the destinations that pay out per message. + 'blocked_prefixes' => env('SMS_GUARD_BLOCKED_PREFIXES', '+800,+808,+870,+878,+881,+882,+883,+888,+979'), + ], + + 'limits' => [ + 'destination' => ['cooldown_seconds' => 60, 'per_hour' => 3, 'per_day' => 5], + 'ip' => ['per_15_minutes' => 5, 'per_day' => 20], + 'company' => ['per_minute' => 10, 'per_hour' => 60, 'per_day' => 200], + 'tenant' => ['per_minute' => null, 'per_hour' => null, 'per_day' => null], + 'probation' => ['days' => 7, 'per_day' => 20], + 'platform' => [ + 'public' => ['per_minute' => 100, 'per_hour' => 1500, 'per_day' => 10000], + 'trusted' => ['per_minute' => 100, 'per_hour' => 1500, 'per_day' => 10000], + ], + ], + + 'breaker' => [ + 'pause_minutes' => 60, + 'alert_percentages' => [50, 80], + 'distinct_destinations_per_10m' => 25, + 'auto_pauses_before_manual' => 2, + ], + + // Per-purpose overrides, keyed by the verification code's `for` value. + 'purposes' => [ + '2fa' => ['trusted' => true, 'destination' => ['cooldown_seconds' => 20, 'per_hour' => 10, 'per_day' => 30]], + 'admin_test' => ['trusted' => true], + 'admin_user_verification' => ['trusted' => true], + 'storefront_login' => ['destination' => ['cooldown_seconds' => 30, 'per_hour' => 5, 'per_day' => 10]], + 'fleetops_customer_login' => ['destination' => ['cooldown_seconds' => 30, 'per_hour' => 5, 'per_day' => 10]], + 'driver_login' => ['destination' => ['cooldown_seconds' => 30, 'per_hour' => 5, 'per_day' => 10]], + ], + ], ]; diff --git a/src/Console/Commands/SmsGuardMode.php b/src/Console/Commands/SmsGuardMode.php new file mode 100644 index 00000000..46643e1b --- /dev/null +++ b/src/Console/Commands/SmsGuardMode.php @@ -0,0 +1,59 @@ +argument('mode'); + + if ($mode !== null) { + $mode = strtolower(trim((string) $mode)); + if (!in_array($mode, [SmsPolicy::MODE_ENFORCE, SmsPolicy::MODE_MONITOR], true)) { + $this->error('Mode must be "enforce" or "monitor".'); + + return self::FAILURE; + } + + SmsPolicy::update(['mode' => $mode]); + $this->info('SMS guard mode set to ' . $mode . '.'); + } + + $settings = SmsPolicy::settings(); + $this->line('Enabled: ' . ($settings['enabled'] ? 'yes' : 'no')); + $this->line('Mode: ' . $settings['mode']); + + $pauses = SmsGuard::make()->breaker()->pauses(); + if (!$pauses) { + $this->line('Pauses: none'); + + return self::SUCCESS; + } + + $this->line('Pauses:'); + $this->table(['Scope', 'Until', 'Reason', 'Automatic'], collect($pauses)->map(fn ($pause, $scope) => [ + $scope, + $pause['until'] === null ? 'unpaused manually' : date('c', $pause['until']), + $pause['reason'], + $pause['auto'] ? 'yes' : 'no', + ])->values()->all()); + + return self::SUCCESS; + } +} diff --git a/src/Console/Commands/SmsGuardPause.php b/src/Console/Commands/SmsGuardPause.php new file mode 100644 index 00000000..0111b934 --- /dev/null +++ b/src/Console/Commands/SmsGuardPause.php @@ -0,0 +1,67 @@ + or tenant:store:} + {--minutes= : how long to pause for; omit to pause until sms:unpause} + {--reason=manual : why, shown on the admin page and in alerts}'; + + protected $description = 'Pause SMS sending for a company, a store/network or the platform'; + + public function handle(): int + { + $scope = static::resolveScope((string) $this->argument('scope')); + if ($scope === null) { + $this->error('No company matches "' . $this->argument('scope') . '". Pass a company uuid or public id, "platform", or a scope key.'); + + return self::FAILURE; + } + + $minutes = $this->option('minutes'); + $minutes = $minutes === null || $minutes === '' ? null : max(1, (int) $minutes); + + $pause = SmsGuard::make()->breaker()->pause($scope, $minutes, (string) $this->option('reason'), false, static::companyUuidFromScope($scope)); + + $this->info('SMS paused for ' . $scope . ($pause['until'] === null ? ' until sms:unpause is run.' : ' until ' . date('c', $pause['until']) . '.')); + + return self::SUCCESS; + } + + /** + * A scope key for what the operator typed, or null when a company could not be found. + */ + public static function resolveScope(string $input): ?string + { + $input = trim($input); + + if ($input === SmsBreaker::SCOPE_PLATFORM || str_starts_with($input, 'company:') || str_starts_with($input, 'tenant:')) { + return $input; + } + + $company = Company::withoutGlobalScopes() + ->where('uuid', $input) + ->orWhere('public_id', $input) + ->first(['uuid']); + + return $company ? 'company:' . $company->uuid : null; + } + + public static function companyUuidFromScope(string $scope): ?string + { + return str_starts_with($scope, 'company:') ? substr($scope, strlen('company:')) : null; + } +} diff --git a/src/Console/Commands/SmsGuardUnpause.php b/src/Console/Commands/SmsGuardUnpause.php new file mode 100644 index 00000000..50f0ca39 --- /dev/null +++ b/src/Console/Commands/SmsGuardUnpause.php @@ -0,0 +1,31 @@ +}'; + + protected $description = 'Resume SMS sending for a paused company, store/network or the platform'; + + public function handle(): int + { + $scope = SmsGuardPause::resolveScope((string) $this->argument('scope')); + if ($scope === null) { + $this->error('No company matches "' . $this->argument('scope') . '".'); + + return self::FAILURE; + } + + SmsGuard::make()->breaker()->unpause($scope); + $this->info('SMS resumed for ' . $scope . '.'); + + return self::SUCCESS; + } +} diff --git a/src/Exceptions/Handler.php b/src/Exceptions/Handler.php index 4abc3167..467861b0 100644 --- a/src/Exceptions/Handler.php +++ b/src/Exceptions/Handler.php @@ -15,7 +15,9 @@ class Handler extends ExceptionHandler * * @var array */ - protected $dontReport = []; + protected $dontReport = [ + SmsBlockedException::class, + ]; /** * A list of the inputs that are never flashed for validation exceptions. @@ -79,6 +81,12 @@ public function report(\Throwable $exception) */ public function render($request, \Throwable $exception) { + // A refused SMS answers with its own status (422 bad number, 429 limited with + // Retry-After, 423 paused) and the same error envelope as every other API error. + if ($exception instanceof SmsBlockedException) { + return response()->json(['errors' => [$exception->getMessage()]], $exception->status(), $exception->headers()); + } + if ($this->shouldManuallyHandleException($exception)) { return $this->manuallyHandleException($exception); } diff --git a/src/Exceptions/SmsBlockedException.php b/src/Exceptions/SmsBlockedException.php new file mode 100644 index 00000000..d792f219 --- /dev/null +++ b/src/Exceptions/SmsBlockedException.php @@ -0,0 +1,78 @@ + $scope])); + } + + public static function unavailable(array $context = []): static + { + return new static(self::REASON_UNAVAILABLE, 'Text messages are temporarily unavailable. Try again later.', 503, 60, $context); + } + + public function reason(): string + { + return $this->reason; + } + + public function status(): int + { + return $this->status; + } + + public function retryAfter(): ?int + { + return $this->retryAfter; + } + + public function context(): array + { + return $this->context; + } + + /** + * Headers for the HTTP response: Retry-After when there is a time to wait. + */ + public function headers(): array + { + return $this->retryAfter !== null ? ['Retry-After' => (string) $this->retryAfter] : []; + } +} diff --git a/src/Http/Controllers/Internal/v1/SettingController.php b/src/Http/Controllers/Internal/v1/SettingController.php index be04ac17..775ea427 100644 --- a/src/Http/Controllers/Internal/v1/SettingController.php +++ b/src/Http/Controllers/Internal/v1/SettingController.php @@ -589,7 +589,7 @@ public function testSmsProviderConfig(AdminRequest $request) $result = null; try { - $result = (new SmsService())->send($phone, $message, [], $provider); + $result = (new SmsService())->send($phone, $message, ['purpose' => 'admin_test'], $provider); if (is_array($result) && data_get($result, 'success') === false) { $status = 'error'; $responseMessage = data_get($result, 'error', 'SMS provider returned an error.'); diff --git a/src/Http/Controllers/Internal/v1/UserController.php b/src/Http/Controllers/Internal/v1/UserController.php index df4f6089..abee4653 100644 --- a/src/Http/Controllers/Internal/v1/UserController.php +++ b/src/Http/Controllers/Internal/v1/UserController.php @@ -1355,7 +1355,7 @@ public function sendVerification(Request $request, string $id) try { if ($channel === 'phone') { $url = UserContactVerificationRequested::urlFor($verificationCode); - $result = app(SmsService::class)->send($value, 'Verify your ' . config('app.name') . ' phone number: ' . $url); + $result = app(SmsService::class)->send($value, 'Verify your ' . config('app.name') . ' phone number: ' . $url, ['purpose' => 'admin_user_verification']); if (is_array($result) && array_key_exists('success', $result) && !$result['success']) { throw new \RuntimeException((string) ($result['error'] ?? $result['message'] ?? 'The SMS provider refused the message.')); } diff --git a/src/Models/VerificationCode.php b/src/Models/VerificationCode.php index d7789dc3..15049dee 100644 --- a/src/Models/VerificationCode.php +++ b/src/Models/VerificationCode.php @@ -5,6 +5,8 @@ use Fleetbase\Casts\Json; use Fleetbase\Mail\VerificationMail; use Fleetbase\Services\SmsService; +use Fleetbase\Support\Sms\SmsContext; +use Fleetbase\Support\Sms\SmsGuard; use Fleetbase\Support\Utils; use Fleetbase\Traits\Expirable; use Fleetbase\Traits\HasMetaAttributes; @@ -263,14 +265,36 @@ public static function generateEmailVerificationFor($subject, $for = 'email_veri * * @return static returns the verification code instance after persisting it to the database and sending the SMS * - * @throws \Exception throws an exception if the SMS cannot be sent + * @throws \Fleetbase\Exceptions\SmsBlockedException when the SMS guard refuses the send; no code row is written + * @throws \Exception throws an exception if the SMS cannot be sent */ public static function generateSmsVerificationFor($subject, $for = 'phone_verification', array $options = []) { - $expireAfter = data_get($options, 'expireAfter'); + $expireAfter = data_get($options, 'expireAfter'); + $destination = data_get($subject, 'phone'); + $companyUuid = data_get($options, 'company_uuid') ?? session('company') ?? data_get($subject, 'company_uuid'); + + // Ask the guard BEFORE the code row exists: a refused send must leave nothing + // behind, and the budgets must be counted even when the provider later fails. + $reservation = null; + if ($destination) { + $reservation = SmsGuard::make()->reserve(SmsContext::resolve((string) $destination, [ + 'purpose' => $for, + 'company_uuid' => $companyUuid, + 'source' => data_get($options, 'source'), + 'subject' => $subject, + ])); + } + + $meta = (array) data_get($options, 'meta', []); + if ($reservation && $reservation->isGuarded()) { + // Who asked for this code and from where, so an incident traces from the table. + $meta['trace'] = $reservation->trace(); + } + $verificationCode = static::generateFor($subject, $for, false); $verificationCode->expires_at = $expireAfter === null ? Carbon::now()->addHour() : $expireAfter; - $verificationCode->meta = data_get($options, 'meta', []); + $verificationCode->meta = $meta; $verificationCode->save(); // Get message @@ -280,11 +304,10 @@ public static function generateSmsVerificationFor($subject, $for = 'phone_verifi $message = $messageCallback($verificationCode); } - // SMS service options - $smsOptions = []; + // SMS service options, including the reservation so the send is counted once + $smsOptions = ['sms_reservation' => $reservation]; // Check for company-specific sender ID - $companyUuid = data_get($options, 'company_uuid') ?? session('company') ?? data_get($subject, 'company_uuid'); if ($companyUuid) { $company = Company::select(['uuid', 'options'])->find($companyUuid); diff --git a/src/Notifications/SmsGuardAlert.php b/src/Notifications/SmsGuardAlert.php new file mode 100644 index 00000000..e83761ad --- /dev/null +++ b/src/Notifications/SmsGuardAlert.php @@ -0,0 +1,100 @@ +companyScope() ?? 'platform'); + $mode = (string) ($data['mode'] ?? 'enforce'); + $where = 'Scope ' . $scope . ($context->companyUuid ? ' (company ' . $context->companyUuid . ')' : '') . ', purpose ' . $context->purpose . ', source ' . $context->source . '.'; + $paused = array_key_exists('paused_minutes', $data) + ? ($mode === 'monitor' + ? 'The guard is in monitor mode, so nothing was paused.' + : ($data['paused_minutes'] === null + ? 'SMS is paused for this scope until an administrator unpauses it.' + : 'SMS is paused for this scope for ' . $data['paused_minutes'] . ' minutes.')) + : null; + + [$title, $what] = match ($signal) { + 'company_daily_budget' => ['tenant hit its daily SMS budget', 'The tenant sent ' . ($data['sends_today'] ?? '?') . ' messages today against a budget of ' . ($data['budget'] ?? '?') . '.'], + 'company_hourly_budget_repeated' => ['tenant hit its hourly SMS budget twice', 'The tenant reached its hourly budget of ' . ($data['budget'] ?? '?') . ' messages in ' . ($data['hours_at_budget_today'] ?? '?') . ' separate hours today.'], + 'distinct_destinations' => ['burst of SMS to new numbers', 'One tenant texted ' . ($data['distinct_destinations'] ?? '?') . ' distinct numbers inside ' . ($data['window_minutes'] ?? 10) . ' minutes. This is the SMS pumping pattern.'], + 'company_budget_threshold' => ['tenant at ' . ($data['percent'] ?? '?') . '% of its daily SMS budget', 'The tenant has sent ' . ($data['sends_today'] ?? '?') . ' of ' . ($data['budget'] ?? '?') . ' messages today.'], + 'platform_budget_threshold' => ['platform at ' . ($data['percent'] ?? '?') . '% of its hourly SMS budget', 'All tenants together have sent ' . ($data['sends_this_hour'] ?? '?') . ' of ' . ($data['budget'] ?? '?') . ' messages this hour.'], + default => ['SMS guard signal: ' . $signal, 'The SMS guard raised ' . $signal . '.'], + }; + + $lines = array_values(array_filter([ + $what, + $where, + $context->ip ? 'Last request from ' . $context->ip . ($context->userAgent ? ' (' . $context->userAgent . ')' : '') . ', to ' . $context->destination . '.' : 'Last message to ' . $context->destination . '.', + $paused, + 'Review the sms.blocked and sms.sent log entries for this scope. Manage pauses and the mode with the sms:pause, sms:unpause and sms:mode console commands.', + ])); + + return new static($signal, $title, $lines, array_merge($data, ['context' => $context->toArray()])); + } + + /** + * @return array + */ + public function via($notifiable) + { + return $notifiable instanceof User ? ['mail', 'database'] : ['mail']; + } + + /** + * @return MailMessage + */ + public function toMail($notifiable) + { + $message = (new MailMessage()) + ->error() + ->subject(config('app.name') . ' SMS guard: ' . $this->title); + + foreach ($this->lines as $line) { + $message->line($line); + } + + return $message; + } + + /** + * @return array + */ + public function toArray($notifiable) + { + return [ + 'signal' => $this->signal, + 'title' => $this->title, + 'lines' => $this->lines, + 'data' => $this->data, + ]; + } +} diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php index 25a165aa..a7392c06 100644 --- a/src/Providers/CoreServiceProvider.php +++ b/src/Providers/CoreServiceProvider.php @@ -103,6 +103,9 @@ class CoreServiceProvider extends ServiceProvider \Fleetbase\Console\Commands\DeleteUser::class, \Fleetbase\Console\Commands\BackupDatabase::class, \Fleetbase\Console\Commands\TelemetryPing::class, + \Fleetbase\Console\Commands\SmsGuardMode::class, + \Fleetbase\Console\Commands\SmsGuardPause::class, + \Fleetbase\Console\Commands\SmsGuardUnpause::class, ]; /** diff --git a/src/Services/SmsService.php b/src/Services/SmsService.php index 4599acca..c9c3848c 100644 --- a/src/Services/SmsService.php +++ b/src/Services/SmsService.php @@ -2,6 +2,9 @@ namespace Fleetbase\Services; +use Fleetbase\Support\Sms\SmsContext; +use Fleetbase\Support\Sms\SmsGuard; +use Fleetbase\Support\Sms\SmsReservation; use Fleetbase\Twilio\Support\Laravel\Facade as Twilio; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; @@ -52,18 +55,39 @@ public function __construct() * * @param string $to Recipient phone number * @param string $text Message text - * @param array $options Additional options (from, provider, twilioParams, etc.) + * @param array $options Additional options (from, provider, twilioParams, etc.). Guard options: + * `purpose` (what the message is for), `company_uuid`, `source`, and + * `sms_reservation` when the caller already reserved the send. * @param string|null $provider Explicitly specify provider (overrides auto-routing) * * @return array Response containing status and provider information * - * @throws \Exception If SMS sending fails + * @throws \Fleetbase\Exceptions\SmsBlockedException If the SMS guard refuses the send + * @throws \Exception If SMS sending fails */ public function send(string $to, string $text, array $options = [], ?string $provider = null): array { // Normalize phone number $normalizedPhone = $this->normalizePhoneNumber($to); + // Every send passes the guard before a provider is called. A caller that already + // reserved the send (VerificationCode) hands its reservation over so it counts once. + $reservation = $options['sms_reservation'] ?? null; + if (!$reservation instanceof SmsReservation) { + $reservation = SmsGuard::make()->reserve(SmsContext::resolve($normalizedPhone, [ + 'purpose' => $options['purpose'] ?? 'sms', + 'company_uuid' => $options['company_uuid'] ?? null, + 'source' => $options['source'] ?? null, + ])); + } + + if ($reservation->isGuarded() && $reservation->country !== null) { + // The validator produced a canonical E.164 number; send to that + $normalizedPhone = $reservation->context->destination; + } + + $options = SmsGuard::providerOptions($options); + // Determine provider $selectedProvider = $provider ?? $this->determineProvider($normalizedPhone); @@ -92,6 +116,13 @@ public function send(string $to, string $text, array $options = [], ?string $pro 'to' => $to, ]); + $accepted = !(is_array($result) && array_key_exists('success', $result) && $result['success'] === false); + SmsGuard::make()->settle($reservation, $accepted, [ + 'provider' => $selectedProvider, + 'message_id' => $result['message_id'] ?? null, + 'error' => $accepted ? null : ($result['error'] ?? $result['message'] ?? null), + ]); + return $result; } catch (\Throwable $e) { Log::error('SMS sending failed', [ @@ -100,6 +131,8 @@ public function send(string $to, string $text, array $options = [], ?string $pro 'error' => $e->getMessage(), ]); + SmsGuard::make()->settle($reservation, false, ['provider' => $selectedProvider, 'error' => $e->getMessage()]); + throw $e; } } diff --git a/src/Support/Sms/PhoneValidator.php b/src/Support/Sms/PhoneValidator.php new file mode 100644 index 00000000..6c56ef91 --- /dev/null +++ b/src/Support/Sms/PhoneValidator.php @@ -0,0 +1,156 @@ + true, 'e164' => $normalized !== '' ? $normalized : null, 'country' => null, 'type' => null, 'reason' => null]; + + $util = PhoneNumberUtil::getInstance(); + $parsed = null; + if ($normalized !== '') { + try { + $parsed = $util->parse($normalized, null); + } catch (NumberParseException $e) { + $parsed = null; + } + } + + if (!$parsed || !$util->isValidNumber($parsed)) { + return $enabled ? self::refuse($result, self::REASON_INVALID) : $result; + } + + $result['e164'] = $util->format($parsed, PhoneNumberFormat::E164); + $result['country'] = $util->getRegionCodeForNumber($parsed) ?: null; + $result['type'] = self::typeName($util->getNumberType($parsed)); + + if (!$enabled) { + return $result; + } + + $allowedTypes = self::upperList($rules['allowed_types'] ?? self::DEFAULT_ALLOWED_TYPES); + if ($allowedTypes && !in_array((string) $result['type'], $allowedTypes, true)) { + return self::refuse($result, self::REASON_TYPE_NOT_ALLOWED); + } + + foreach (self::list($rules['blocked_prefixes'] ?? []) as $prefix) { + $prefix = '+' . ltrim(preg_replace('/[^0-9+]/', '', $prefix), '+'); + if (strlen($prefix) > 1 && str_starts_with($result['e164'], $prefix)) { + return self::refuse($result, self::REASON_PREFIX_BLOCKED); + } + } + + $blockedCountries = self::upperList($rules['blocked_countries'] ?? []); + if ($result['country'] && in_array($result['country'], $blockedCountries, true)) { + return self::refuse($result, self::REASON_COUNTRY_BLOCKED); + } + + $allowedCountries = self::upperList($rules['allowed_countries'] ?? []); + if ($allowedCountries && !in_array((string) $result['country'], $allowedCountries, true)) { + return self::refuse($result, self::REASON_COUNTRY_NOT_ALLOWED); + } + + return $result; + } + + /** + * Strip formatting and give the number a leading `+`, the way the rest of Fleetbase + * expects destinations (a `00` international prefix becomes `+`). + */ + public static function normalize(string $phone): string + { + $digits = preg_replace('/[^0-9+]/', '', trim($phone)) ?? ''; + $digits = preg_replace('/(?!^)\+/', '', $digits) ?? ''; + + if ($digits === '' || $digits === '+') { + return ''; + } + + if (str_starts_with($digits, '00')) { + $digits = '+' . substr($digits, 2); + } + + return str_starts_with($digits, '+') ? $digits : '+' . $digits; + } + + /** + * What to tell the caller. Deliberately says nothing about which rule matched beyond + * "invalid" versus "cannot receive texts", so a list of blocked ranges cannot be probed. + */ + public static function messageFor(?string $reason): string + { + return match ($reason) { + self::REASON_INVALID => 'The phone number provided is not valid.', + self::REASON_TYPE_NOT_ALLOWED => 'The phone number provided cannot receive text messages.', + default => 'Text messages cannot be sent to this phone number.', + }; + } + + /** + * The libphonenumber type as its constant name (MOBILE, PREMIUM_RATE, ...). + */ + public static function typeName(int $type): string + { + $name = array_search($type, (new \ReflectionClass(PhoneNumberType::class))->getConstants(), true); + + return is_string($name) ? $name : 'UNKNOWN'; + } + + private static function refuse(array $result, string $reason): array + { + $result['valid'] = false; + $result['reason'] = $reason; + + return $result; + } + + private static function list(mixed $value): array + { + if (is_string($value)) { + $value = explode(',', $value); + } + + return array_values(array_filter(array_map(fn ($item) => trim((string) $item), (array) $value), fn ($item) => $item !== '')); + } + + private static function upperList(mixed $value): array + { + return array_map('strtoupper', self::list($value)); + } +} diff --git a/src/Support/Sms/SmsBreaker.php b/src/Support/Sms/SmsBreaker.php new file mode 100644 index 00000000..35c22410 --- /dev/null +++ b/src/Support/Sms/SmsBreaker.php @@ -0,0 +1,292 @@ +cache->get($this->key('pause:' . $scope)); + if (!is_array($pause)) { + $pause = SmsPolicy::settings()['pauses'][$scope] ?? null; + } + + if (!is_array($pause)) { + return null; + } + + if (($pause['until'] ?? null) !== null && (int) $pause['until'] <= time()) { + return null; + } + + return $pause; + } + + /** + * Pause a scope for `$minutes`, or until unpaused when null. + */ + public function pause(string $scope, ?int $minutes, string $reason, bool $auto = false, ?string $companyUuid = null): array + { + $pause = [ + 'until' => $minutes === null ? null : time() + ($minutes * 60), + 'reason' => $reason, + 'auto' => $auto, + 'company_uuid' => $companyUuid, + 'at' => time(), + ]; + + if ($minutes === null) { + $this->cache->forever($this->key('pause:' . $scope), $pause); + } else { + $this->cache->put($this->key('pause:' . $scope), $pause, $minutes * 60); + } + + try { + SmsPolicy::update(['pauses' => [$scope => $pause]]); + } catch (\Throwable $e) { + Log::warning('sms.pause_not_persisted', ['scope' => $scope, 'error' => $e->getMessage()]); + } + + Log::warning('sms.paused', array_merge($pause, ['scope' => $scope])); + + return $pause; + } + + public function unpause(string $scope): void + { + $this->cache->forget($this->key('pause:' . $scope)); + + try { + SmsPolicy::update(['pauses' => [$scope => null]]); + } catch (\Throwable $e) { + Log::warning('sms.unpause_not_persisted', ['scope' => $scope, 'error' => $e->getMessage()]); + } + + Log::info('sms.unpaused', ['scope' => $scope]); + } + + /** + * Current pauses from the policy, expired ones dropped. + */ + public function pauses(): array + { + return array_filter(SmsPolicy::settings()['pauses'], fn ($pause) => $pause['until'] === null || $pause['until'] > time()); + } + + /** + * Read the counters after a reserved send and trip whatever crossed a line. + * + * @param array $counters the guard's counters for this send + */ + public function observe(SmsContext $context, array $limits, array $counters, RateLimiter $limiter, bool $monitor, array $settings): void + { + $breaker = $settings['breaker']; + + if ($context->companyUuid && $limits['company']) { + $this->observeCompanyBudget($context, $counters, $limiter, $breaker, $monitor); + } + + if (!$limits['trusted']) { + $this->observeDistinctDestinations($context, $breaker, $monitor); + } + + $this->observePlatform($context, $counters, $limiter, $breaker, $monitor); + } + + protected function observeCompanyBudget(SmsContext $context, array $counters, RateLimiter $limiter, array $breaker, bool $monitor): void + { + $scope = $context->companyScope(); + $day = $this->counter($counters, 'company', 'per_day'); + $hour = $this->counter($counters, 'company', 'per_hour'); + + if ($day) { + $attempts = $limiter->attempts($day['key']); + + if ($attempts >= $day['max']) { + $this->trip($scope, self::SIGNAL_COMPANY_DAILY_BUDGET, $context, $breaker, $monitor, ['sends_today' => $attempts, 'budget' => $day['max']]); + } else { + foreach ($breaker['alert_percentages'] as $percent) { + if ($attempts >= (int) ceil($day['max'] * $percent / 100) && $this->once('alert:' . $scope . ':day:' . $percent . ':' . intdiv(time(), 86400), 86400)) { + $this->alert(self::SIGNAL_BUDGET_THRESHOLD, $context, ['percent' => $percent, 'sends_today' => $attempts, 'budget' => $day['max'], 'scope' => $scope, 'mode' => $monitor ? SmsPolicy::MODE_MONITOR : SmsPolicy::MODE_ENFORCE]); + } + } + } + } + + if ($hour && $limiter->attempts($hour['key']) >= $hour['max']) { + if ($this->once('hour-trip:' . $scope . ':' . intdiv(time(), 3600), 86400)) { + $trips = $this->increment('hour-trips:' . $scope, 86400); + if ($trips >= 2) { + $this->trip($scope, self::SIGNAL_COMPANY_HOURLY_REPEAT, $context, $breaker, $monitor, ['hours_at_budget_today' => $trips, 'budget' => $hour['max']]); + } + } + } + } + + protected function observeDistinctDestinations(SmsContext $context, array $breaker, bool $monitor): void + { + $threshold = $breaker['distinct_destinations_per_10m']; + $scope = $context->tenantScope() ?? $context->companyScope(); + if (!$threshold || !$scope) { + return; + } + + $bucket = intdiv(time(), self::DISTINCT_WINDOW); + if (!$this->once('distinct:' . $scope . ':' . $bucket . ':' . sha1($context->destination), self::DISTINCT_WINDOW)) { + return; + } + + $count = $this->increment('distinct-count:' . $scope . ':' . $bucket, self::DISTINCT_WINDOW); + if ($count >= $threshold) { + $this->trip($scope, self::SIGNAL_DISTINCT_DESTINATIONS, $context, $breaker, $monitor, ['distinct_destinations' => $count, 'window_minutes' => self::DISTINCT_WINDOW / 60]); + } + } + + protected function observePlatform(SmsContext $context, array $counters, RateLimiter $limiter, array $breaker, bool $monitor): void + { + $hour = $this->counter($counters, 'platform', 'per_hour'); + if (!$hour) { + return; + } + + $attempts = $limiter->attempts($hour['key']); + $percent = max($breaker['alert_percentages'] ?: [80]); + if ($attempts >= (int) ceil($hour['max'] * $percent / 100) && $this->once('alert:platform:hour:' . intdiv(time(), 3600), 3600)) { + $this->alert(self::SIGNAL_PLATFORM_THRESHOLD, $context, ['percent' => $percent, 'sends_this_hour' => $attempts, 'budget' => $hour['max'], 'scope' => self::SCOPE_PLATFORM, 'mode' => $monitor ? SmsPolicy::MODE_MONITOR : SmsPolicy::MODE_ENFORCE]); + } + } + + /** + * Pause a scope (unless monitoring) and alert once an hour per signal. + */ + protected function trip(string $scope, string $signal, SmsContext $context, array $breaker, bool $monitor, array $data): void + { + if (!$monitor && $this->pausedUntil($scope)) { + return; + } + + $minutes = $breaker['pause_minutes']; + $reason = $signal; + + if (!$monitor) { + $autoPauses = $context->companyScope() ? $this->increment('auto-pauses:' . $context->companyScope(), 86400) : 1; + if ($autoPauses >= $breaker['auto_pauses_before_manual']) { + $minutes = null; + $reason .= ' (paused ' . $autoPauses . ' times today; an administrator must unpause)'; + } + + $this->pause($scope, $minutes, $reason, true, $context->companyUuid); + } + + if ($this->once('alert:' . $scope . ':' . $signal, self::ALERT_WINDOW)) { + $this->alert($signal, $context, array_merge($data, [ + 'scope' => $scope, + 'paused_minutes' => $minutes, + 'mode' => $monitor ? SmsPolicy::MODE_MONITOR : SmsPolicy::MODE_ENFORCE, + ])); + } + } + + /** + * Send an alert to the administrators. Never throws: an alert that cannot be delivered + * must not fail the send that triggered it. + */ + protected function alert(string $signal, SmsContext $context, array $data): void + { + $alert = SmsGuardAlert::for($signal, $context, $data); + + Log::warning('sms.alert', array_merge(['signal' => $signal], $context->toArray(), $data)); + + try { + $this->dispatchAlert($alert); + } catch (\Throwable $e) { + Log::error('sms.alert_not_sent', ['signal' => $signal, 'error' => $e->getMessage()]); + } + } + + protected function dispatchAlert(SmsGuardAlert $alert): void + { + $admins = SmsPolicy::adminUsers(); + $adminEmails = $admins->pluck('email')->filter()->map(fn ($email) => strtolower(trim((string) $email)))->all(); + $extra = array_values(array_diff(SmsPolicy::alertRecipients(), $adminEmails)); + + if ($admins->isNotEmpty()) { + Notification::sendNow($admins, $alert); + } + + if ($extra) { + Notification::route('mail', $extra)->notifyNow($alert); + } + } + + protected function counter(array $counters, string $scope, string $window): ?array + { + foreach ($counters as $counter) { + if ($counter['scope'] === $scope && $counter['window'] === $window) { + return $counter; + } + } + + return null; + } + + /** + * True the first time a key is seen within its window. + */ + protected function once(string $key, int $seconds): bool + { + return (bool) $this->cache->add($this->key($key), 1, $seconds); + } + + protected function increment(string $key, int $seconds): int + { + $this->cache->add($this->key($key), 0, $seconds); + + return (int) $this->cache->increment($this->key($key)); + } + + protected function key(string $key): string + { + return SmsGuard::CACHE_PREFIX . $key; + } +} diff --git a/src/Support/Sms/SmsContext.php b/src/Support/Sms/SmsContext.php new file mode 100644 index 00000000..1b472110 --- /dev/null +++ b/src/Support/Sms/SmsContext.php @@ -0,0 +1,197 @@ +ip(), + userAgent: $overrides['user_agent'] ?? ($request && $request->userAgent() ? Str::limit((string) $request->userAgent(), 250, '') : null), + subjectType: is_object($subject) ? get_class($subject) : null, + subjectUuid: is_object($subject) && data_get($subject, 'uuid') ? (string) data_get($subject, 'uuid') : null, + ); + } + + /** + * The same context with the destination replaced, after the validator normalised it. + */ + public function withDestination(string $destination): self + { + return new self( + $this->purpose, + $destination, + $this->companyUuid, + $this->tenantType, + $this->tenantUuid, + $this->source, + $this->ip, + $this->userAgent, + $this->subjectType, + $this->subjectUuid, + ); + } + + /** + * Whether an anonymous caller, or one holding only a public storefront key or an + * API credential, triggered this send. These are the sends that fail closed when the + * guard's counters are unreachable. + */ + public function isPublic(): bool + { + return in_array($this->source, [self::SOURCE_STOREFRONT, self::SOURCE_API, self::SOURCE_PUBLIC], true); + } + + public function companyScope(): ?string + { + return $this->companyUuid ? 'company:' . $this->companyUuid : null; + } + + public function tenantScope(): ?string + { + return $this->tenantUuid ? 'tenant:' . ($this->tenantType ?: 'tenant') . ':' . $this->tenantUuid : null; + } + + public function toArray(): array + { + return [ + 'purpose' => $this->purpose, + 'destination' => $this->destination, + 'company_uuid' => $this->companyUuid, + 'tenant_type' => $this->tenantType, + 'tenant_uuid' => $this->tenantUuid, + 'source' => $this->source, + 'ip' => $this->ip, + 'user_agent' => $this->userAgent, + 'subject_type' => $this->subjectType, + 'subject_uuid' => $this->subjectUuid, + ]; + } + + /** + * Which surface a request came in on, from its first path segment. + * + * Onboarding is anonymous even though it lives under the console prefix, so it counts + * as public: a self-signup farm must not get the console's lenient treatment. + */ + public static function detectSource(?Request $request): string + { + if (!$request) { + return self::SOURCE_SYSTEM; + } + + $path = trim((string) $request->path(), '/'); + if ($path === '') { + return self::SOURCE_SYSTEM; + } + + $first = Str::before($path, '/'); + $storefrontPrefix = trim((string) config('storefront.api.routing.prefix', 'storefront'), '/'); + $internalPrefix = trim((string) config('fleetbase.api.routing.internal_prefix', 'int'), '/'); + + if ($storefrontPrefix !== '' && $first === $storefrontPrefix) { + return self::SOURCE_STOREFRONT; + } + + if ($internalPrefix !== '' && $first === $internalPrefix) { + return Str::contains($path, '/onboard') ? self::SOURCE_PUBLIC : self::SOURCE_CONSOLE; + } + + if ($first === 'public' || Str::contains($path, '/public/')) { + return self::SOURCE_PUBLIC; + } + + if ($first === 'v1' || Str::contains($path, '/v1/')) { + return self::SOURCE_API; + } + + return self::SOURCE_SYSTEM; + } + + private static function sessionValues(): array + { + try { + $session = session(); + + return [ + 'company' => $session->get('company'), + 'storefront_store' => $session->get('storefront_store'), + 'storefront_network' => $session->get('storefront_network'), + ]; + } catch (\Throwable $e) { + return []; + } + } + + private static function currentRequest(): ?Request + { + try { + $request = request(); + + return $request instanceof Request ? $request : null; + } catch (\Throwable $e) { + return null; + } + } +} diff --git a/src/Support/Sms/SmsGuard.php b/src/Support/Sms/SmsGuard.php new file mode 100644 index 00000000..a8fc0f71 --- /dev/null +++ b/src/Support/Sms/SmsGuard.php @@ -0,0 +1,297 @@ +bound(static::class)) { + return app(static::class); + } + } catch (\Throwable $e) { + // no container: fall through to a fresh guard + } + + return new static(); + } + + /** + * Check a send against the policy and, when it may go ahead, count it. + * + * @throws SmsBlockedException when the send is refused + */ + public function reserve(SmsContext $context): SmsReservation + { + $settings = SmsPolicy::settings(); + if (!$settings['enabled']) { + return new SmsReservation($context, SmsReservation::DECISION_BYPASSED); + } + + $monitor = $settings['mode'] === SmsPolicy::MODE_MONITOR; + $limits = SmsPolicy::limitsFor($context, $settings); + $wouldBlock = null; + + $validation = PhoneValidator::validate($context->destination, array_merge($settings['validation'], ['allowed_countries' => $limits['allowed_countries']])); + if (!$validation['valid']) { + $wouldBlock = $this->refuse($context, SmsBlockedException::invalidDestination((string) $validation['reason'], $context->toArray()), $monitor); + } elseif ($validation['e164'] && $validation['e164'] !== $context->destination) { + $context = $context->withDestination($validation['e164']); + } + + try { + $counters = $this->counters($context, $limits); + + if ($wouldBlock === null) { + foreach ($this->pauseScopes($context, $limits) as $scope) { + if ($pause = $this->breaker()->pausedUntil($scope)) { + $retryAfter = $pause['until'] !== null ? max(1, (int) $pause['until'] - time()) : null; + $wouldBlock = $this->refuse($context, SmsBlockedException::paused($scope, $retryAfter, $context->toArray()), $monitor); + break; + } + } + } + + if ($wouldBlock === null) { + foreach ($counters as $counter) { + if ($this->limiter()->tooManyAttempts($counter['key'], $counter['max'])) { + $wouldBlock = $this->refuse($context, SmsBlockedException::limited($counter['reason'], (int) $this->limiter()->availableIn($counter['key']), array_merge($context->toArray(), ['scope' => $counter['scope'], 'window' => $counter['window']])), $monitor); + break; + } + } + } + + foreach ($counters as $counter) { + $this->limiter()->hit($counter['key'], $counter['decay']); + } + + $this->breaker()->observe($context, $limits, $counters, $this->limiter(), $monitor, $settings); + } catch (SmsBlockedException $e) { + throw $e; + } catch (\Throwable $e) { + // The counter store is unreachable. Public sends fail closed: refusing to pump + // SMS blind is the point. Trusted and internal sends (2FA, console actions) fail + // open so staff can still sign in while the store is being fixed. + Log::warning('sms.guard_unavailable', array_merge($context->toArray(), ['error' => $e->getMessage()])); + + if (!$monitor && $context->isPublic() && !$limits['trusted']) { + throw SmsBlockedException::unavailable($context->toArray()); + } + + return new SmsReservation($context, SmsReservation::DECISION_DEGRADED, null, $validation['country'], $validation['type']); + } + + return new SmsReservation($context, $wouldBlock === null ? SmsReservation::DECISION_ALLOWED : SmsReservation::DECISION_MONITOR, $wouldBlock, $validation['country'], $validation['type']); + } + + /** + * Record how a reserved send ended. + */ + public function settle(SmsReservation $reservation, bool $success, array $details = []): void + { + if ($reservation->settled) { + return; + } + + $reservation->settled = true; + + if (!$reservation->isGuarded()) { + return; + } + + $entry = array_merge($reservation->toArray(), Arr::only($details, ['provider', 'message_id', 'error'])); + + if ($success) { + Log::info('sms.sent', $entry); + } else { + Log::warning('sms.failed', $entry); + } + } + + public function breaker(): SmsBreaker + { + return $this->breaker ??= new SmsBreaker($this->cache()); + } + + public function limiter(): RateLimiter + { + return $this->limiter ??= new RateLimiter($this->cache()); + } + + /** + * Options a provider should see: everything the caller passed minus the guard's own. + */ + public static function providerOptions(array $options): array + { + return Arr::except($options, self::OPTIONS); + } + + /** + * The counters a send is checked against and then counted in, in check order. + * + * @return array + */ + public function counters(SmsContext $context, array $limits): array + { + $counters = []; + $destination = $limits['destination']; + $purpose = $context->purpose; + $number = $context->destination; + + if (($destination['cooldown_seconds'] ?? null) > 0) { + $counters[] = $this->counter('dest:' . $purpose . ':' . $number . ':cooldown', 1, (int) $destination['cooldown_seconds'], 'cooldown', 'destination', 'cooldown'); + } + if (($destination['per_hour'] ?? null) !== null) { + $counters[] = $this->counter('dest:' . $purpose . ':' . $number . ':hour', (int) $destination['per_hour'], 3600, 'destination_limit', 'destination', 'per_hour'); + } + if (($destination['per_day'] ?? null) !== null) { + $counters[] = $this->counter('dest:' . $purpose . ':' . $number . ':day', (int) $destination['per_day'], 86400, 'destination_limit', 'destination', 'per_day'); + } + + if ($limits['ip'] && $context->ip) { + $owner = $context->companyScope() ?? SmsBreaker::SCOPE_PLATFORM; + if (($limits['ip']['per_15_minutes'] ?? null) !== null) { + $counters[] = $this->counter('ip:' . $owner . ':' . $context->ip . ':15m', (int) $limits['ip']['per_15_minutes'], 900, 'ip_limit', 'ip', 'per_15_minutes'); + } + if (($limits['ip']['per_day'] ?? null) !== null) { + $counters[] = $this->counter('ip:' . $owner . ':' . $context->ip . ':day', (int) $limits['ip']['per_day'], 86400, 'ip_limit', 'ip', 'per_day'); + } + } + + if ($limits['tenant'] && $context->tenantScope()) { + foreach (SmsPolicy::WINDOWS as $window => $decay) { + if (($limits['tenant'][$window] ?? null) !== null) { + $counters[] = $this->counter($context->tenantScope() . ':' . $window, (int) $limits['tenant'][$window], $decay, 'tenant_limit', 'tenant', $window); + } + } + } + + if ($limits['company'] && $context->companyScope()) { + foreach (SmsPolicy::WINDOWS as $window => $decay) { + if (($limits['company'][$window] ?? null) !== null) { + $counters[] = $this->counter($context->companyScope() . ':' . $window, (int) $limits['company'][$window], $decay, 'company_limit', 'company', $window); + } + } + } + + foreach (SmsPolicy::WINDOWS as $window => $decay) { + if (($limits['platform'][$window] ?? null) !== null) { + $counters[] = $this->counter('platform:' . $limits['bucket'] . ':' . $window, (int) $limits['platform'][$window], $decay, 'platform_limit', 'platform', $window); + } + } + + return $counters; + } + + /** + * Which pauses stop this send. Trusted purposes (2FA, admin actions) answer only to a + * platform pause: a pumped tenant must not lock its own staff out of the console. + */ + protected function pauseScopes(SmsContext $context, array $limits): array + { + $scopes = [SmsBreaker::SCOPE_PLATFORM]; + + if ($limits['trusted']) { + return $scopes; + } + + if ($context->companyScope()) { + $scopes[] = $context->companyScope(); + } + + if ($context->tenantScope()) { + $scopes[] = $context->tenantScope(); + } + + return $scopes; + } + + /** + * Log a refusal and, unless monitoring, throw it. Returns the reason when monitoring. + */ + protected function refuse(SmsContext $context, SmsBlockedException $exception, bool $monitor): string + { + $entry = array_merge($context->toArray(), ['reason' => $exception->reason(), 'status' => $exception->status(), 'retry_after' => $exception->retryAfter()]); + + if ($monitor) { + Log::warning('sms.would_block', $entry); + + return $exception->reason(); + } + + Log::warning('sms.blocked', $entry); + + throw $exception; + } + + protected function counter(string $key, int $max, int $decay, string $reason, string $scope, string $window): array + { + return ['key' => self::CACHE_PREFIX . $key, 'max' => $max, 'decay' => $decay, 'reason' => $reason, 'scope' => $scope, 'window' => $window]; + } + + /** + * The cache store the counters live in. Must be shared by every worker and task (Redis + * in production); the configured `sms.guard.cache_store` is used when it resolves. + */ + protected function cache(): CacheRepository + { + if ($this->cache) { + return $this->cache; + } + + $cache = app('cache'); + $store = config('sms.guard.cache_store'); + + if (is_object($cache) && method_exists($cache, 'store')) { + if (filled($store)) { + try { + return $this->cache = $cache->store($store); + } catch (\Throwable $e) { + Log::warning('sms.guard_cache_store_unavailable', ['store' => $store, 'error' => $e->getMessage()]); + } + } + + return $this->cache = $cache->store(); + } + + return $this->cache = $cache; + } +} diff --git a/src/Support/Sms/SmsPolicy.php b/src/Support/Sms/SmsPolicy.php new file mode 100644 index 00000000..0457b4b4 --- /dev/null +++ b/src/Support/Sms/SmsPolicy.php @@ -0,0 +1,427 @@ + 60, 'per_hour' => 3600, 'per_day' => 86400]; + + /** + * The policy from the environment alone. + */ + public static function defaults(): array + { + return static::normalize((array) config('sms.guard', [])); + } + + /** + * The administrator's stored override, raw, or [] when there is none. + */ + public static function stored(): array + { + try { + $value = Setting::where('key', 'system.' . static::SETTING_KEY)->value('value'); + } catch (\Throwable $e) { + return []; + } + + return is_array($value) ? $value : []; + } + + /** + * The effective policy: defaults with the stored override applied. + */ + public static function settings(): array + { + try { + $stored = Cache::remember(static::SETTINGS_CACHE_KEY, static::SETTINGS_CACHE_TTL, fn () => static::stored()); + } catch (\Throwable $e) { + $stored = []; + } + + return static::normalize(static::merge((array) config('sms.guard', []), is_array($stored) ? $stored : [])); + } + + /** + * Replace the stored override and make it effective at once. + */ + public static function store(array $override): array + { + Setting::configureSystem(static::SETTING_KEY, $override); + static::flush(); + + return static::settings(); + } + + /** + * Change part of the stored override, keeping the rest. + */ + public static function update(array $changes): array + { + return static::store(static::merge(static::stored(), $changes)); + } + + /** + * Drop the stored override so the environment defaults apply again. + */ + public static function reset(): array + { + Setting::where('key', 'system.' . static::SETTING_KEY)->delete(); + static::flush(); + + return static::settings(); + } + + public static function flush(): void + { + try { + Cache::forget(static::SETTINGS_CACHE_KEY); + } catch (\Throwable $e) { + // the next read falls back to the database + } + } + + public static function isEnabled(): bool + { + return static::settings()['enabled']; + } + + public static function mode(): string + { + return static::settings()['mode']; + } + + /** + * Coerce a policy into its canonical shape. + */ + public static function normalize(array $settings): array + { + $limits = (array) ($settings['limits'] ?? []); + $validation = (array) ($settings['validation'] ?? []); + $breaker = (array) ($settings['breaker'] ?? []); + $platform = (array) ($limits['platform'] ?? []); + $mode = strtolower(trim((string) ($settings['mode'] ?? static::MODE_ENFORCE))); + + $purposes = []; + foreach ((array) ($settings['purposes'] ?? []) as $purpose => $rules) { + if (!is_string($purpose) || $purpose === '' || !is_array($rules)) { + continue; + } + + $purposes[$purpose] = [ + 'trusted' => (bool) ($rules['trusted'] ?? false), + 'destination' => static::destinationLimits((array) ($rules['destination'] ?? []), false), + ]; + } + + $overrides = []; + foreach ((array) ($settings['overrides'] ?? []) as $override) { + $companyUuid = data_get($override, 'company_uuid'); + if (!is_string($companyUuid) || $companyUuid === '') { + continue; + } + + $overrides[$companyUuid] = [ + 'company_uuid' => $companyUuid, + 'unlimited' => (bool) data_get($override, 'unlimited', false), + 'per_minute' => static::limit(data_get($override, 'per_minute')), + 'per_hour' => static::limit(data_get($override, 'per_hour')), + 'per_day' => static::limit(data_get($override, 'per_day')), + 'allowed_countries' => static::upperList(data_get($override, 'allowed_countries', [])), + 'note' => (string) data_get($override, 'note', ''), + ]; + } + + $pauses = []; + foreach ((array) ($settings['pauses'] ?? []) as $scope => $pause) { + if (!is_string($scope) || $scope === '' || !is_array($pause)) { + continue; + } + + $pauses[$scope] = [ + 'until' => isset($pause['until']) && $pause['until'] !== null ? (int) $pause['until'] : null, + 'reason' => (string) ($pause['reason'] ?? ''), + 'auto' => (bool) ($pause['auto'] ?? false), + 'company_uuid' => isset($pause['company_uuid']) ? (string) $pause['company_uuid'] : null, + 'at' => (int) ($pause['at'] ?? 0), + ]; + } + + return [ + 'enabled' => (bool) ($settings['enabled'] ?? true), + 'mode' => in_array($mode, [static::MODE_ENFORCE, static::MODE_MONITOR], true) ? $mode : static::MODE_ENFORCE, + 'cache_store' => filled($settings['cache_store'] ?? null) ? (string) $settings['cache_store'] : null, + 'alert_emails' => static::emails($settings['alert_emails'] ?? []), + 'validation' => [ + 'enabled' => (bool) ($validation['enabled'] ?? true), + 'allowed_types' => static::upperList($validation['allowed_types'] ?? PhoneValidator::DEFAULT_ALLOWED_TYPES) ?: PhoneValidator::DEFAULT_ALLOWED_TYPES, + 'blocked_countries' => static::upperList($validation['blocked_countries'] ?? []), + 'blocked_prefixes' => static::list($validation['blocked_prefixes'] ?? []), + ], + 'limits' => [ + 'destination' => static::destinationLimits((array) ($limits['destination'] ?? []), true), + 'ip' => [ + 'per_15_minutes' => static::limit(data_get($limits, 'ip.per_15_minutes', 5)), + 'per_day' => static::limit(data_get($limits, 'ip.per_day', 20)), + ], + 'company' => static::windowLimits((array) ($limits['company'] ?? []), ['per_minute' => 10, 'per_hour' => 60, 'per_day' => 200]), + 'tenant' => static::windowLimits((array) ($limits['tenant'] ?? []), []), + 'probation' => [ + 'days' => max(0, (int) data_get($limits, 'probation.days', 7)), + 'per_day' => static::limit(data_get($limits, 'probation.per_day', 20)), + ], + 'platform' => [ + static::BUCKET_PUBLIC => static::windowLimits((array) ($platform[static::BUCKET_PUBLIC] ?? []), ['per_minute' => 100, 'per_hour' => 1500, 'per_day' => 10000]), + static::BUCKET_TRUSTED => static::windowLimits((array) ($platform[static::BUCKET_TRUSTED] ?? []), ['per_minute' => 100, 'per_hour' => 1500, 'per_day' => 10000]), + ], + ], + 'breaker' => [ + 'pause_minutes' => max(1, (int) ($breaker['pause_minutes'] ?? 60)), + 'alert_percentages' => array_values(array_filter(array_map('intval', static::list($breaker['alert_percentages'] ?? [50, 80])), fn ($pct) => $pct > 0 && $pct < 100)), + 'distinct_destinations_per_10m' => static::limit($breaker['distinct_destinations_per_10m'] ?? 25), + 'auto_pauses_before_manual' => max(1, (int) ($breaker['auto_pauses_before_manual'] ?? 2)), + ], + 'purposes' => $purposes, + 'overrides' => array_values($overrides), + 'pauses' => $pauses, + ]; + } + + /** + * The administrator's exception for a company, if any. + */ + public static function overrideFor(?string $companyUuid, ?array $settings = null): ?array + { + if (!$companyUuid) { + return null; + } + + foreach (($settings ?? static::settings())['overrides'] as $override) { + if ($override['company_uuid'] === $companyUuid) { + return $override; + } + } + + return null; + } + + public static function isTrusted(string $purpose, ?array $settings = null): bool + { + return (bool) data_get($settings ?? static::settings(), 'purposes.' . $purpose . '.trusted', false); + } + + /** + * The limits that apply to one send. + * + * @return array{trusted: bool, bucket: string, destination: array, ip: ?array, tenant: ?array, company: ?array, company_unlimited: bool, platform: array, allowed_countries: array, probation: bool} + */ + public static function limitsFor(SmsContext $context, ?array $settings = null): array + { + $settings ??= static::settings(); + $override = static::overrideFor($context->companyUuid, $settings); + $purpose = $settings['purposes'][$context->purpose] ?? ['trusted' => false, 'destination' => []]; + $trusted = (bool) ($purpose['trusted'] ?? false); + $probation = false; + + $destination = $settings['limits']['destination']; + foreach ((array) ($purpose['destination'] ?? []) as $key => $value) { + if ($value !== null) { + $destination[$key] = $value; + } + } + + // Trusted purposes (2FA, admin actions) answer to their own platform bucket and the + // destination limits only: a pumped tenant must still be able to sign its staff in. + $company = null; + if ($context->companyUuid && !$trusted && !($override['unlimited'] ?? false)) { + $company = $settings['limits']['company']; + + if ($override) { + foreach (array_keys(static::WINDOWS) as $window) { + if ($override[$window] !== null) { + $company[$window] = $override[$window]; + } + } + } elseif (static::isOnProbation($context->companyUuid, $settings['limits']['probation']['days'])) { + $probation = true; + $limit = $settings['limits']['probation']['per_day']; + if ($limit !== null) { + $company['per_day'] = $company['per_day'] === null ? $limit : min($company['per_day'], $limit); + } + } + } + + $tenant = null; + if (!$trusted && $context->tenantUuid) { + $tenant = array_filter($settings['limits']['tenant'], fn ($value) => $value !== null) ?: null; + } + + $bucket = $trusted ? static::BUCKET_TRUSTED : static::BUCKET_PUBLIC; + + return [ + 'trusted' => $trusted, + 'bucket' => $bucket, + 'destination' => $destination, + 'ip' => $trusted ? null : $settings['limits']['ip'], + 'tenant' => $tenant, + 'company' => $company, + 'company_unlimited' => (bool) ($override['unlimited'] ?? false), + 'platform' => $settings['limits']['platform'][$bucket], + 'allowed_countries' => $override['allowed_countries'] ?? [], + 'probation' => $probation, + ]; + } + + /** + * Whether a company is younger than the probation period. Unknown companies are not. + */ + public static function isOnProbation(?string $companyUuid, int $days): bool + { + if (!$companyUuid || $days <= 0) { + return false; + } + + try { + $createdAt = Cache::remember('sms_guard:company_created:' . $companyUuid, static::COMPANY_CACHE_TTL, function () use ($companyUuid) { + $value = Company::withoutGlobalScopes()->where('uuid', $companyUuid)->value('created_at'); + + return $value ? Carbon::parse($value)->toIso8601String() : ''; + }); + } catch (\Throwable $e) { + return false; + } + + return filled($createdAt) && Carbon::parse($createdAt)->gt(Carbon::now()->subDays($days)); + } + + /** + * Where alerts go: the addresses in the policy, plus every system administrator. + * Tenants are never alerted. + * + * @return array + */ + public static function alertRecipients(?array $settings = null): array + { + $emails = ($settings ?? static::settings())['alert_emails']; + + foreach (static::adminUsers() as $admin) { + if (filled($admin->email)) { + $emails[] = strtolower(trim((string) $admin->email)); + } + } + + return array_values(array_unique(array_filter($emails, fn ($email) => filter_var($email, FILTER_VALIDATE_EMAIL)))); + } + + /** + * The system administrators, for in-app notifications. + * + * @return Collection + */ + public static function adminUsers(): Collection + { + try { + return User::withoutGlobalScopes()->where('type', 'admin')->whereNull('deleted_at')->get(); + } catch (\Throwable $e) { + return new Collection(); + } + } + + /** + * Apply an override to a base policy. Associative arrays merge key by key; lists and + * scalars are replaced, so a stored `allowed_types` of two entries does not pick up a + * third from the defaults. + */ + public static function merge(array $base, array $override): array + { + foreach ($override as $key => $value) { + if (is_array($value) && isset($base[$key]) && is_array($base[$key]) && !array_is_list($value) && !array_is_list($base[$key])) { + $base[$key] = static::merge($base[$key], $value); + } else { + $base[$key] = $value; + } + } + + return $base; + } + + protected static function destinationLimits(array $limits, bool $withDefaults): array + { + $defaults = $withDefaults ? ['cooldown_seconds' => 60, 'per_hour' => 3, 'per_day' => 5] : ['cooldown_seconds' => null, 'per_hour' => null, 'per_day' => null]; + + return [ + 'cooldown_seconds' => static::limit($limits['cooldown_seconds'] ?? $defaults['cooldown_seconds']), + 'per_hour' => static::limit($limits['per_hour'] ?? $defaults['per_hour']), + 'per_day' => static::limit($limits['per_day'] ?? $defaults['per_day']), + ]; + } + + protected static function windowLimits(array $limits, array $defaults): array + { + $result = []; + foreach (array_keys(static::WINDOWS) as $window) { + $result[$window] = static::limit(array_key_exists($window, $limits) ? $limits[$window] : ($defaults[$window] ?? null)); + } + + return $result; + } + + /** + * A limit as an integer, or null for "no limit" (null, '', 'none', 'unlimited'). + */ + protected static function limit(mixed $value): ?int + { + if ($value === null || $value === '' || (is_string($value) && in_array(strtolower(trim($value)), ['none', 'unlimited', 'null'], true))) { + return null; + } + + return max(0, (int) $value); + } + + protected static function list(mixed $value): array + { + if (is_string($value)) { + $value = explode(',', $value); + } + + return array_values(array_filter(array_map(fn ($item) => trim((string) $item), (array) $value), fn ($item) => $item !== '')); + } + + protected static function upperList(mixed $value): array + { + return array_map('strtoupper', static::list($value)); + } + + protected static function emails(mixed $value): array + { + return array_values(array_unique(array_filter(array_map('strtolower', static::list($value)), fn ($email) => filter_var($email, FILTER_VALIDATE_EMAIL)))); + } +} diff --git a/src/Support/Sms/SmsReservation.php b/src/Support/Sms/SmsReservation.php new file mode 100644 index 00000000..d4c049db --- /dev/null +++ b/src/Support/Sms/SmsReservation.php @@ -0,0 +1,73 @@ +decision !== self::DECISION_BYPASSED; + } + + /** + * What a verification code row keeps about the send, so an incident can be traced + * from the database: who it was for, where it came from and what the guard decided. + */ + public function trace(): array + { + return array_filter([ + 'company_uuid' => $this->context->companyUuid, + 'tenant_type' => $this->context->tenantType, + 'tenant_uuid' => $this->context->tenantUuid, + 'source' => $this->context->source, + 'ip' => $this->context->ip, + 'user_agent' => $this->context->userAgent, + 'destination_country' => $this->country, + 'decision' => $this->decision, + 'would_block' => $this->wouldBlock, + ], fn ($value) => $value !== null); + } + + public function toArray(): array + { + return array_merge($this->context->toArray(), [ + 'decision' => $this->decision, + 'would_block' => $this->wouldBlock, + 'destination_country' => $this->country, + 'destination_type' => $this->type, + ]); + } +} diff --git a/src/routes.php b/src/routes.php index 1a123c0c..9242e3d1 100644 --- a/src/routes.php +++ b/src/routes.php @@ -133,7 +133,9 @@ function ($router) { $router->get('should-onboard', 'OnboardController@shouldOnboard'); $router->post('create-account', 'OnboardController@createAccount'); $router->post('verify-email', 'OnboardController@verifyEmail'); - $router->post('send-verification-sms', 'OnboardController@sendVerificationSms'); + // Anyone with a signup session can ask for the SMS again; a tight + // per-address limit sits in front of the guard's own budgets. + $router->post('send-verification-sms', 'OnboardController@sendVerificationSms')->middleware(Illuminate\Routing\Middleware\ThrottleRequests::class . ':3,1,onboard-sms'); $router->post('send-verification-email', 'OnboardController@sendVerificationEmail'); } ); diff --git a/tests/Pest.php b/tests/Pest.php index df6f950c..fb334573 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -538,6 +538,12 @@ function bind_test_container(array $config = []): Container $container->make('config')->set($key, $value); } + // The SMS guard is off unless a test turns it on: its counters live in the shared + // cache, so otherwise one file's sends would rate-limit the next file's. + if (!$container->make('config')->has('sms.guard.enabled')) { + $container->make('config')->set('sms.guard.enabled', false); + } + $container->instance('request', Request::create('/int/v1/test', 'GET')); // fresh resource transformer registry per test so registrations never leak between tests diff --git a/tests/Unit/CallProSmsServiceTest.php b/tests/Unit/CallProSmsServiceTest.php index 7d6a678e..d53d68e7 100644 --- a/tests/Unit/CallProSmsServiceTest.php +++ b/tests/Unit/CallProSmsServiceTest.php @@ -50,6 +50,7 @@ function config($key = null, $default = null) 'routing_rules' => [ '+976' => SmsService::PROVIDER_CALLPRO, ], + 'guard' => ['enabled' => false], ], ])); $app->instance('log', new NullLogger()); diff --git a/tests/Unit/Console/SmsGuardCommandsTest.php b/tests/Unit/Console/SmsGuardCommandsTest.php new file mode 100644 index 00000000..27b1fb58 --- /dev/null +++ b/tests/Unit/Console/SmsGuardCommandsTest.php @@ -0,0 +1,143 @@ + 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]; + + $container = bind_test_container([ + 'sms.guard' => ['enabled' => true, 'mode' => 'enforce'], + 'database.default' => 'mysql', + 'database.connections.mysql' => $connection, + 'fleetbase.connection.db' => 'mysql', + ]); + $container->instance('cache', new CacheRepository(new ArrayStore())); + app()->forgetInstance(SmsGuard::class); + Facade::clearResolvedInstances(); + + $capsule = new Capsule($container); + $capsule->addConnection($connection, 'mysql'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + $capsule->getDatabaseManager()->setDefaultConnection('mysql'); + $container->instance('db', $capsule->getDatabaseManager()); + Facade::clearResolvedInstance('db'); + + $schema = $capsule->getConnection('mysql')->getSchemaBuilder(); + $schema->create('settings', function ($table) { + $table->increments('id'); + $table->string('key')->unique(); + $table->text('value')->nullable(); + }); + $schema->create('companies', function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable(); + $table->string('name')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + $capsule->getConnection('mysql')->table('companies')->insert([ + ['uuid' => 'company-acme', 'public_id' => 'company_acme', 'name' => 'Acme'], + ]); + + return $capsule; +} + +function sms_guard_command_tester(object $command): CommandTester +{ + $command->setLaravel(app()); + + return new CommandTester($command); +} + +afterEach(function () { + EloquentModel::unsetConnectionResolver(); + EloquentModel::clearBootedModels(); + Facade::clearResolvedInstances(); +}); + +test('sms:mode shows and switches the guard mode at runtime', function () { + sms_guard_commands_database(); + + $show = sms_guard_command_tester(new SmsGuardMode()); + $show->execute([]); + + $switch = sms_guard_command_tester(new SmsGuardMode()); + $switch->execute(['mode' => 'Monitor']); + + $bad = sms_guard_command_tester(new SmsGuardMode()); + $bad->execute(['mode' => 'loud']); + + expect($show->getStatusCode())->toBe(0) + ->and($show->getDisplay())->toContain('Mode: enforce') + ->and($show->getDisplay())->toContain('Pauses: none') + ->and($switch->getStatusCode())->toBe(0) + ->and($switch->getDisplay())->toContain('SMS guard mode set to monitor.') + ->and(SmsPolicy::settings()['mode'])->toBe('monitor') + ->and(SmsPolicy::stored())->toBe(['mode' => 'monitor']) + ->and($bad->getStatusCode())->toBe(1) + ->and($bad->getDisplay())->toContain('Mode must be "enforce" or "monitor".'); +}); + +test('sms:pause and sms:unpause manage a company pause by public id and list it', function () { + sms_guard_commands_database(); + + $pause = sms_guard_command_tester(new SmsGuardPause()); + $pause->execute(['scope' => 'company_acme', '--minutes' => '15', '--reason' => 'incident 42']); + + $listed = sms_guard_command_tester(new SmsGuardMode()); + $listed->execute([]); + + $paused = SmsGuard::make()->breaker()->pausedUntil('company:company-acme'); + + $missing = sms_guard_command_tester(new SmsGuardPause()); + $missing->execute(['scope' => 'company_nobody']); + + $platform = sms_guard_command_tester(new SmsGuardPause()); + $platform->execute(['scope' => 'platform']); + + $unpause = sms_guard_command_tester(new SmsGuardUnpause()); + $unpause->execute(['scope' => 'company-acme']); + + $unpauseMissing = sms_guard_command_tester(new SmsGuardUnpause()); + $unpauseMissing->execute(['scope' => 'company_nobody']); + + expect($pause->getStatusCode())->toBe(0) + ->and($pause->getDisplay())->toContain('SMS paused for company:company-acme until ') + ->and($paused)->not->toBeNull() + ->and($paused['reason'])->toBe('incident 42') + ->and($paused['auto'])->toBeFalse() + ->and($paused['until'])->toBeGreaterThan(time()) + ->and($listed->getDisplay())->toContain('company:company-acme') + ->and($listed->getDisplay())->toContain('incident 42') + ->and($missing->getStatusCode())->toBe(1) + ->and($platform->getStatusCode())->toBe(0) + ->and($platform->getDisplay())->toContain('SMS paused for platform until sms:unpause is run.') + ->and(SmsGuard::make()->breaker()->pausedUntil('platform')['until'])->toBeNull() + ->and($unpause->getStatusCode())->toBe(0) + ->and(SmsGuard::make()->breaker()->pausedUntil('company:company-acme'))->toBeNull() + ->and(SmsPolicy::settings()['pauses'])->not->toHaveKey('company:company-acme') + ->and(SmsPolicy::settings()['pauses'])->toHaveKey('platform') + ->and($unpauseMissing->getStatusCode())->toBe(1) + ->and(SmsGuardPause::companyUuidFromScope('company:abc'))->toBe('abc') + ->and(SmsGuardPause::companyUuidFromScope('platform'))->toBeNull(); +}); diff --git a/tests/Unit/Models/VerificationCodeGuardTest.php b/tests/Unit/Models/VerificationCodeGuardTest.php new file mode 100644 index 00000000..28bac120 --- /dev/null +++ b/tests/Unit/Models/VerificationCodeGuardTest.php @@ -0,0 +1,184 @@ +messages[] = compact('to', 'message', 'mediaUrls', 'params'); + + return (object) ['sid' => 'SM-guarded']; + } +} + +function verification_code_guard_database(array $guard = []): VerificationCodeGuardTwilioFake +{ + EloquentModel::clearBootedModels(); + session()->flush(); + + $connection = [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]; + + $container = bind_test_container([ + 'app.name' => 'Fleetbase', + 'sms.default_provider' => 'twilio', + 'sms.guard' => array_replace_recursive([ + 'enabled' => true, + 'mode' => 'enforce', + 'limits' => [ + 'destination' => ['cooldown_seconds' => 60, 'per_hour' => 3, 'per_day' => 5], + 'company' => ['per_minute' => 100, 'per_hour' => 100, 'per_day' => 100], + 'probation' => ['days' => 0], + ], + 'breaker' => ['alert_percentages' => [], 'distinct_destinations_per_10m' => 100], + ], $guard), + 'database.default' => 'testing', + 'database.connections.testing' => $connection, + 'database.connections.mysql' => $connection, + 'fleetbase.connection.db' => 'testing', + ]); + $container->instance('cache', new CacheRepository(new ArrayStore())); + $container->instance('request', Request::create('/storefront/v1/customers/request-creation-code', 'POST', [], [], [], ['REMOTE_ADDR' => '203.0.113.9', 'HTTP_USER_AGENT' => 'StorefrontApp/3.1'])); + $container->instance('responsecache', new class { + public function clear(): void + { + } + }); + $twilio = new VerificationCodeGuardTwilioFake(); + $container->instance('twilio', $twilio); + Facade::clearResolvedInstances(); + + $capsule = new Capsule($container); + $capsule->addConnection($connection, 'testing'); + $capsule->addConnection($connection, 'mysql'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + $capsule->getDatabaseManager()->setDefaultConnection('testing'); + $container->instance('db', $capsule->getDatabaseManager()); + Facade::clearResolvedInstance('db'); + + $schema = $capsule->getConnection('testing')->getSchemaBuilder(); + $schema->create('verification_codes', function ($table) { + $table->string('uuid')->primary(); + $table->string('subject_uuid')->nullable()->index(); + $table->string('subject_type')->nullable(); + $table->string('code')->nullable(); + $table->string('for')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->text('meta')->nullable(); + $table->string('status')->nullable(); + $table->timestamp('deleted_at')->nullable(); + $table->timestamps(); + }); + // Company lives on the core connection, like the verification code model test. + $capsule->getConnection('mysql')->getSchemaBuilder()->create('companies', function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable()->index(); + $table->string('slug')->nullable(); + $table->text('options')->nullable(); + $table->timestamp('deleted_at')->nullable(); + $table->timestamps(); + }); + + session(['company' => 'company-1', 'storefront_store' => 'store-1']); + + return $twilio; +} + +function verification_code_guard_subject(string $phone): User +{ + $user = new User(); + $user->setRawAttributes(['uuid' => 'user-guarded', 'name' => 'Ada Lovelace', 'phone' => $phone], true); + + return $user; +} + +afterEach(function () { + Carbon::setTestNow(); + session()->flush(); + EloquentModel::unsetConnectionResolver(); + EloquentModel::clearBootedModels(); + Facade::clearResolvedInstances(); +}); + +it('writes no verification code row when the guard refuses the send', function () { + $twilio = verification_code_guard_database(); + + $refusal = null; + try { + VerificationCode::generateSmsVerificationFor(verification_code_guard_subject('+4471'), 'storefront_create_customer', ['meta' => ['identity' => '+4471']]); + } catch (SmsBlockedException $e) { + $refusal = $e; + } + + expect($refusal)->toBeInstanceOf(SmsBlockedException::class) + ->and($refusal->status())->toBe(422) + ->and(VerificationCode::query()->count())->toBe(0) + ->and($twilio->messages)->toBe([]); +}); + +it('traces an allowed send on the code row, sends once and refuses the repeat inside the cooldown', function () { + $twilio = verification_code_guard_database(); + + $code = VerificationCode::generateSmsVerificationFor(verification_code_guard_subject('+447400100123'), 'storefront_create_customer', [ + 'meta' => ['identity' => '+447400100123'], + 'messageCallback' => fn (VerificationCode $verificationCode) => 'Code: ' . $verificationCode->code, + ]); + + $repeat = null; + try { + VerificationCode::generateSmsVerificationFor(verification_code_guard_subject('+447400100123'), 'storefront_create_customer', ['meta' => ['identity' => '+447400100123']]); + } catch (SmsBlockedException $e) { + $repeat = $e; + } + + $entries = collect(app('log')->entries)->pluck(1); + + expect($code->exists)->toBeTrue() + ->and($code->meta['identity'])->toBe('+447400100123') + ->and($code->meta['trace'])->toMatchArray([ + 'company_uuid' => 'company-1', + 'tenant_type' => 'store', + 'tenant_uuid' => 'store-1', + 'source' => 'storefront', + 'ip' => '203.0.113.9', + 'user_agent' => 'StorefrontApp/3.1', + 'destination_country' => 'GB', + 'decision' => 'allowed', + ]) + ->and($twilio->messages)->toHaveCount(1) + ->and($twilio->messages[0]['to'])->toBe('+447400100123') + ->and($twilio->messages[0]['message'])->toBe('Code: ' . $code->code) + ->and($repeat)->toBeInstanceOf(SmsBlockedException::class) + ->and($repeat->status())->toBe(429) + ->and(VerificationCode::query()->count())->toBe(1) + ->and($entries->filter(fn ($name) => $name === 'sms.sent')->count())->toBe(1) + ->and($entries->filter(fn ($name) => $name === 'sms.blocked')->count())->toBe(1); +}); + +it('leaves the code row untouched when the guard is disabled', function () { + $twilio = verification_code_guard_database(['enabled' => false]); + + $code = VerificationCode::generateSmsVerificationFor(verification_code_guard_subject('+447400100123'), 'phone_login', ['meta' => ['channel' => 'sms']]); + + expect($code->meta)->toBe(['channel' => 'sms']) + ->and($twilio->messages)->toHaveCount(1) + ->and(collect(app('log')->entries)->pluck(1)->all())->not->toContain('sms.sent'); +}); diff --git a/tests/Unit/MultiProviderSmsServiceTest.php b/tests/Unit/MultiProviderSmsServiceTest.php index 1283d32c..29929317 100644 --- a/tests/Unit/MultiProviderSmsServiceTest.php +++ b/tests/Unit/MultiProviderSmsServiceTest.php @@ -156,6 +156,7 @@ function config($key = null, $default = null) '+44' => SmsService::PROVIDER_MESSAGEBIRD, ], 'providers' => [], + 'guard' => ['enabled' => false], ], ])); $app->instance('log', new NullLogger()); diff --git a/tests/Unit/SmsServiceGuardTest.php b/tests/Unit/SmsServiceGuardTest.php new file mode 100644 index 00000000..5ad59e67 --- /dev/null +++ b/tests/Unit/SmsServiceGuardTest.php @@ -0,0 +1,144 @@ +dispatches[] = compact('to', 'text', 'options'); + + if ($this->failure) { + throw $this->failure; + } + + return ['success' => true, 'message_id' => 'SM-probe']; + } +} + +function sms_service_guard_setup(array $guard = []): SmsGuard +{ + session()->flush(); + + $container = bind_test_container([ + 'sms.default_provider' => 'twilio', + 'sms.guard' => array_replace_recursive([ + 'enabled' => true, + 'mode' => 'enforce', + 'limits' => [ + 'destination' => ['cooldown_seconds' => 60, 'per_hour' => 3, 'per_day' => 5], + 'probation' => ['days' => 0], + ], + 'breaker' => ['alert_percentages' => [], 'distinct_destinations_per_10m' => 100], + ], $guard), + ]); + $cache = new CacheRepository(new ArrayStore()); + $container->instance('cache', $cache); + $container->instance('request', Request::create('/v1/drivers/login-with-sms', 'POST', [], [], [], ['REMOTE_ADDR' => '198.51.100.7'])); + Facade::clearResolvedInstances(); + session(['company' => 'company-api']); + + $guard = new SmsGuard($cache); + $container->instance(SmsGuard::class, $guard); + + return $guard; +} + +afterEach(function () { + session()->flush(); + app()->forgetInstance(SmsGuard::class); + Facade::clearResolvedInstances(); +}); + +test('sms service refuses a blocked destination before any provider is called', function () { + sms_service_guard_setup(); + $service = new SmsServiceGuardProbe(); + + $refusal = null; + try { + $service->send('+449098790123', 'hello', ['purpose' => 'driver_login']); + } catch (SmsBlockedException $e) { + $refusal = $e; + } + + expect($refusal)->toBeInstanceOf(SmsBlockedException::class) + ->and($refusal->status())->toBe(422) + ->and($service->dispatches)->toBe([]); +}); + +test('sms service reserves, normalises the destination, strips guard options and settles', function () { + $guard = sms_service_guard_setup(); + $service = new SmsServiceGuardProbe(); + + $result = $service->send('44 7400 100123', 'hello', ['purpose' => 'driver_login', 'company_uuid' => 'company-api', 'twilioParams' => ['from' => 'ACME']]); + $entries = collect(app('log')->entries); + + $cooldown = null; + try { + $service->send('+447400100123', 'hello again', ['purpose' => 'driver_login']); + } catch (SmsBlockedException $e) { + $cooldown = $e; + } + + expect($result['provider'])->toBe('twilio') + ->and($service->dispatches)->toHaveCount(1) + ->and($service->dispatches[0]['to'])->toBe('+447400100123') + ->and($service->dispatches[0]['options'])->toBe(['twilioParams' => ['from' => 'ACME']]) + ->and($entries->where(1, 'sms.sent'))->toHaveCount(1) + ->and($entries->where(1, 'sms.sent')->first()[2])->toMatchArray(['purpose' => 'driver_login', 'company_uuid' => 'company-api', 'source' => 'api', 'ip' => '198.51.100.7', 'message_id' => 'SM-probe']) + ->and($cooldown)->toBeInstanceOf(SmsBlockedException::class) + ->and($cooldown->status())->toBe(429) + ->and($guard->limiter()->attempts(SmsGuard::CACHE_PREFIX . 'company:company-api:per_day'))->toBe(1); +}); + +test('sms service counts a send once when the caller hands over its reservation, and settles failures', function () { + $guard = sms_service_guard_setup(['limits' => ['destination' => ['cooldown_seconds' => 0]]]); + $service = new SmsServiceGuardProbe(); + + $reservation = $guard->reserve(SmsContext::resolve('+447400100123', ['purpose' => 'driver_login'])); + $service->send('+447400100123', 'hello', ['sms_reservation' => $reservation, 'purpose' => 'driver_login']); + + $service->failure = new RuntimeException('carrier refused'); + $thrown = null; + try { + $service->send('+447400100124', 'hello', ['purpose' => 'driver_login']); + } catch (RuntimeException $e) { + $thrown = $e; + } + + $entries = collect(app('log')->entries); + + expect($reservation->settled)->toBeTrue() + ->and($guard->limiter()->attempts(SmsGuard::CACHE_PREFIX . 'company:company-api:per_day'))->toBe(2) + ->and($thrown?->getMessage())->toBe('carrier refused') + ->and($entries->where(1, 'sms.failed'))->toHaveCount(1) + ->and($entries->where(1, 'sms.failed')->first()[2]['error'])->toBe('carrier refused'); +}); + +test('sms service treats a provider result with success false as a failed send', function () { + sms_service_guard_setup(['limits' => ['destination' => ['cooldown_seconds' => 0]]]); + $service = new class extends SmsService { + protected function sendViaTwilio(string $to, string $text, array $options = []): array + { + return ['success' => false, 'error' => 'invalid sender']; + } + }; + + $service->send('+447400100123', 'hello', ['purpose' => 'driver_login']); + + $failed = collect(app('log')->entries)->where(1, 'sms.failed')->first(); + + expect($failed)->not->toBeNull() + ->and($failed[2]['error'])->toBe('invalid sender'); +}); diff --git a/tests/Unit/Support/Sms/PhoneValidatorTest.php b/tests/Unit/Support/Sms/PhoneValidatorTest.php new file mode 100644 index 00000000..6936705c --- /dev/null +++ b/tests/Unit/Support/Sms/PhoneValidatorTest.php @@ -0,0 +1,70 @@ +toMatchArray(['valid' => true, 'e164' => '+447400100123', 'country' => 'GB', 'type' => 'MOBILE', 'reason' => null]) + ->and($mn)->toMatchArray(['valid' => true, 'e164' => '+97699112233', 'country' => 'MN', 'type' => 'MOBILE']) + ->and($us)->toMatchArray(['valid' => true, 'e164' => '+14155552671', 'country' => 'US', 'type' => 'FIXED_LINE_OR_MOBILE']); +}); + +test('phone validator does not guess a country for a number without one', function () { + $local = PhoneValidator::validate('(415) 555-2671'); + + expect($local['valid'])->toBeFalse() + ->and($local['reason'])->toBe(PhoneValidator::REASON_INVALID); +}); + +test('phone validator normalises formatting and international prefixes', function () { + expect(PhoneValidator::normalize('0044 7400 100123'))->toBe('+447400100123') + ->and(PhoneValidator::normalize('+1 (415) 555-2671'))->toBe('+14155552671') + ->and(PhoneValidator::normalize('97699112233'))->toBe('+97699112233') + ->and(PhoneValidator::normalize('+'))->toBe('') + ->and(PhoneValidator::normalize(''))->toBe('') + ->and(PhoneValidator::validate('+14155552671')['e164'])->toBe('+14155552671'); +}); + +test('phone validator refuses numbers that are invalid or cannot receive texts', function () { + $short = PhoneValidator::validate('+4471'); + $garbage = PhoneValidator::validate('not-a-number'); + $premium = PhoneValidator::validate('+449098790123'); + $land = PhoneValidator::validate('+442079460000'); + + expect($short)->toMatchArray(['valid' => false, 'reason' => PhoneValidator::REASON_INVALID]) + ->and($garbage)->toMatchArray(['valid' => false, 'e164' => null, 'reason' => PhoneValidator::REASON_INVALID]) + ->and($premium)->toMatchArray(['valid' => false, 'type' => 'PREMIUM_RATE', 'reason' => PhoneValidator::REASON_TYPE_NOT_ALLOWED]) + ->and($land)->toMatchArray(['valid' => false, 'type' => 'FIXED_LINE', 'reason' => PhoneValidator::REASON_TYPE_NOT_ALLOWED]); +}); + +test('phone validator applies blocked prefixes, blocked countries and a company allowlist', function () { + $prefix = PhoneValidator::validate('+447400100123', ['blocked_prefixes' => '+800, +4477']); + $country = PhoneValidator::validate('+447400100123', ['blocked_countries' => 'gb,fr']); + $allowed = PhoneValidator::validate('+447400100123', ['allowed_countries' => ['MN', 'SG']]); + $fine = PhoneValidator::validate('+447400100123', ['blocked_prefixes' => ['+800'], 'blocked_countries' => ['FR'], 'allowed_countries' => ['GB']]); + + expect($prefix['reason'])->toBe(PhoneValidator::REASON_PREFIX_BLOCKED) + ->and($country['reason'])->toBe(PhoneValidator::REASON_COUNTRY_BLOCKED) + ->and($allowed['reason'])->toBe(PhoneValidator::REASON_COUNTRY_NOT_ALLOWED) + ->and($fine['valid'])->toBeTrue(); +}); + +test('phone validator only normalises when validation is disabled', function () { + $invalid = PhoneValidator::validate('+4471', ['enabled' => false]); + $premium = PhoneValidator::validate('+449098790123', ['enabled' => false, 'blocked_prefixes' => ['+44']]); + + expect($invalid)->toMatchArray(['valid' => true, 'e164' => '+4471', 'reason' => null]) + ->and($premium)->toMatchArray(['valid' => true, 'e164' => '+449098790123', 'type' => 'PREMIUM_RATE', 'reason' => null]); +}); + +test('phone validator explains refusals without naming the rule', function () { + expect(PhoneValidator::messageFor(PhoneValidator::REASON_INVALID))->toBe('The phone number provided is not valid.') + ->and(PhoneValidator::messageFor(PhoneValidator::REASON_TYPE_NOT_ALLOWED))->toBe('The phone number provided cannot receive text messages.') + ->and(PhoneValidator::messageFor(PhoneValidator::REASON_PREFIX_BLOCKED))->toBe('Text messages cannot be sent to this phone number.') + ->and(PhoneValidator::messageFor(PhoneValidator::REASON_COUNTRY_NOT_ALLOWED))->toBe('Text messages cannot be sent to this phone number.') + ->and(PhoneValidator::typeName(1))->toBe('MOBILE') + ->and(PhoneValidator::typeName(999))->toBe('UNKNOWN'); +}); diff --git a/tests/Unit/Support/Sms/SmsGuardTest.php b/tests/Unit/Support/Sms/SmsGuardTest.php new file mode 100644 index 00000000..f3df9854 --- /dev/null +++ b/tests/Unit/Support/Sms/SmsGuardTest.php @@ -0,0 +1,358 @@ + */ + public array $alerts = []; + + protected function dispatchAlert(SmsGuardAlert $alert): void + { + $this->alerts[] = $alert; + } +} + +class SmsGuardTestBrokenCache extends CacheRepository +{ + public function add($key, $value, $ttl = null) + { + throw new RuntimeException('redis down'); + } +} + +/** + * @return array{0: SmsGuard, 1: SmsGuardTestBreaker, 2: CacheRepository} + */ +function sms_guard_test_setup(array $guard = [], string $path = '/storefront/v1/customers/request-creation-code', ?CacheRepository $cache = null): array +{ + session()->flush(); + + $config = SmsPolicy::merge([ + 'enabled' => true, + 'mode' => 'enforce', + 'alert_emails' => '', + 'validation' => ['enabled' => true, 'blocked_prefixes' => '+881'], + 'limits' => [ + 'destination' => ['cooldown_seconds' => 60, 'per_hour' => 3, 'per_day' => 5], + 'ip' => ['per_15_minutes' => 50, 'per_day' => 100], + 'company' => ['per_minute' => 100, 'per_hour' => 100, 'per_day' => 100], + 'tenant' => ['per_minute' => null, 'per_hour' => null, 'per_day' => null], + 'probation' => ['days' => 0, 'per_day' => 20], + 'platform' => [ + 'public' => ['per_minute' => 500, 'per_hour' => 500, 'per_day' => 500], + 'trusted' => ['per_minute' => 500, 'per_hour' => 500, 'per_day' => 500], + ], + ], + 'breaker' => [ + 'pause_minutes' => 30, + 'alert_percentages' => [50], + 'distinct_destinations_per_10m' => 50, + 'auto_pauses_before_manual' => 2, + ], + 'purposes' => ['2fa' => ['trusted' => true]], + ], $guard); + + $container = bind_test_container(['sms.guard' => $config]); + $cache ??= new CacheRepository(new ArrayStore()); + $container->instance('cache', $cache); + $container->instance('request', Request::create($path, 'POST', [], [], [], ['REMOTE_ADDR' => '203.0.113.9', 'HTTP_USER_AGENT' => 'StorefrontApp/3.1'])); + Facade::clearResolvedInstances(); + session(['company' => 'company-1', 'storefront_store' => 'store-1', 'storefront_network' => null]); + + $breaker = new SmsGuardTestBreaker($cache); + + return [new SmsGuard($cache, $breaker), $breaker, $cache]; +} + +function sms_guard_context(string $destination, string $purpose = 'storefront_create_customer'): SmsContext +{ + return SmsContext::resolve($destination, ['purpose' => $purpose]); +} + +function sms_guard_refusal(SmsGuard $guard, SmsContext $context): SmsBlockedException +{ + try { + $guard->reserve($context); + } catch (SmsBlockedException $e) { + return $e; + } + + throw new RuntimeException('Expected the guard to refuse ' . $context->destination); +} + +afterEach(function () { + Carbon::setTestNow(); + session()->flush(); + Facade::clearResolvedInstances(); +}); + +test('sms context is resolved from the session and request', function () { + sms_guard_test_setup(); + + $context = sms_guard_context('+447400100123'); + + expect($context->companyUuid)->toBe('company-1') + ->and($context->tenantType)->toBe('store') + ->and($context->tenantUuid)->toBe('store-1') + ->and($context->source)->toBe(SmsContext::SOURCE_STOREFRONT) + ->and($context->ip)->toBe('203.0.113.9') + ->and($context->userAgent)->toBe('StorefrontApp/3.1') + ->and($context->isPublic())->toBeTrue() + ->and($context->companyScope())->toBe('company:company-1') + ->and($context->tenantScope())->toBe('tenant:store:store-1') + ->and($context->withDestination('+15550001111')->destination)->toBe('+15550001111') + ->and(SmsContext::detectSource(Request::create('/v1/drivers/login-with-sms')))->toBe(SmsContext::SOURCE_API) + ->and(SmsContext::detectSource(Request::create('/int/v1/onboard/send-verification-sms')))->toBe(SmsContext::SOURCE_PUBLIC) + ->and(SmsContext::detectSource(Request::create('/int/v1/two-fa/resend')))->toBe(SmsContext::SOURCE_CONSOLE) + ->and(SmsContext::detectSource(Request::create('/public/track/ABC/codes')))->toBe(SmsContext::SOURCE_PUBLIC) + ->and(SmsContext::detectSource(Request::create('/')))->toBe(SmsContext::SOURCE_SYSTEM) + ->and(SmsContext::detectSource(null))->toBe(SmsContext::SOURCE_SYSTEM); +}); + +test('sms guard is bypassed entirely when disabled', function () { + [$guard] = sms_guard_test_setup(['enabled' => false]); + + $reservation = $guard->reserve(sms_guard_context('not-a-number')); + + expect($reservation->decision)->toBe(SmsReservation::DECISION_BYPASSED) + ->and($reservation->isGuarded())->toBeFalse() + ->and($reservation->trace())->toBe([]); +}); + +test('sms guard refuses destinations that can never be texted with a 422', function () { + [$guard] = sms_guard_test_setup(); + + $invalid = sms_guard_refusal($guard, sms_guard_context('+4471')); + $premium = sms_guard_refusal($guard, sms_guard_context('+449098790123')); + $blocked = sms_guard_refusal($guard, sms_guard_context('+88216123456789')); + + expect($invalid->status())->toBe(422) + ->and($invalid->reason())->toBe('invalid_destination') + ->and($invalid->headers())->toBe([]) + ->and($premium->status())->toBe(422) + ->and($premium->reason())->toBe('type_not_allowed') + ->and($blocked->status())->toBe(422) + ->and($blocked->reason())->toBeIn(['prefix_blocked', 'invalid_destination']) + ->and($guard->limiter()->attempts(SmsGuard::CACHE_PREFIX . 'company:company-1:per_day'))->toBe(0); +}); + +test('sms guard enforces the per-destination cooldown and caps with a retry-after', function () { + [$guard] = sms_guard_test_setup(['limits' => ['destination' => ['cooldown_seconds' => 60, 'per_hour' => 2, 'per_day' => 5]]]); + Carbon::setTestNow(Carbon::parse('2026-10-13 10:00:00', 'UTC')); + + $first = $guard->reserve(sms_guard_context('+44 7400 100123')); + $again = sms_guard_refusal($guard, sms_guard_context('+447400100123')); + + Carbon::setTestNow(Carbon::parse('2026-10-13 10:01:01', 'UTC')); + $second = $guard->reserve(sms_guard_context('+447400100123')); + + Carbon::setTestNow(Carbon::parse('2026-10-13 10:02:02', 'UTC')); + $third = sms_guard_refusal($guard, sms_guard_context('+447400100123')); + $other = $guard->reserve(sms_guard_context('+447400100124')); + + expect($first->decision)->toBe(SmsReservation::DECISION_ALLOWED) + ->and($first->context->destination)->toBe('+447400100123') + ->and($first->country)->toBe('GB') + ->and($first->type)->toBe('MOBILE') + ->and($again->status())->toBe(429) + ->and($again->reason())->toBe('cooldown') + ->and($again->retryAfter())->toBeGreaterThanOrEqual(1) + ->and($again->headers())->toHaveKey('Retry-After') + ->and($second->decision)->toBe(SmsReservation::DECISION_ALLOWED) + ->and($third->reason())->toBe('destination_limit') + ->and($third->status())->toBe(429) + ->and($other->decision)->toBe(SmsReservation::DECISION_ALLOWED) + ->and($guard->limiter()->attempts(SmsGuard::CACHE_PREFIX . 'company:company-1:per_day'))->toBe(3) + ->and($guard->limiter()->attempts(SmsGuard::CACHE_PREFIX . 'platform:public:per_day'))->toBe(3); +}); + +test('sms guard hits the company budget, pauses the company and alerts the admins once', function () { + [$guard, $breaker] = sms_guard_test_setup([ + 'limits' => ['destination' => ['cooldown_seconds' => 0, 'per_hour' => null, 'per_day' => null], 'company' => ['per_minute' => 100, 'per_hour' => 100, 'per_day' => 3]], + 'breaker' => ['alert_percentages' => []], + ]); + + $guard->reserve(sms_guard_context('+447400100001')); + $guard->reserve(sms_guard_context('+447400100002')); + $third = $guard->reserve(sms_guard_context('+447400100003')); + $paused = sms_guard_refusal($guard, sms_guard_context('+447400100004')); + $pause = $breaker->pausedUntil('company:company-1'); + + // Staff 2FA for the paused company still goes out: trusted purposes skip tenant pauses + // and the company budget, and count in their own platform bucket. + $twoFa = $guard->reserve(sms_guard_context('+447400100005', '2fa')); + + // Another tenant is untouched. + session(['company' => 'company-2', 'storefront_store' => 'store-9']); + $otherTenant = $guard->reserve(sms_guard_context('+447400100006')); + + expect($third->decision)->toBe(SmsReservation::DECISION_ALLOWED) + ->and($paused->status())->toBe(423) + ->and($paused->reason())->toBe(SmsBlockedException::REASON_PAUSED) + ->and($paused->retryAfter())->toBeGreaterThan(0) + ->and($paused->context()['scope'])->toBe('company:company-1') + ->and($pause['auto'])->toBeTrue() + ->and($pause['reason'])->toBe(SmsBreaker::SIGNAL_COMPANY_DAILY_BUDGET) + ->and($pause['until'])->toBeGreaterThan(time()) + ->and($breaker->alerts)->toHaveCount(1) + ->and($breaker->alerts[0]->signal)->toBe(SmsBreaker::SIGNAL_COMPANY_DAILY_BUDGET) + ->and($breaker->alerts[0]->data['sends_today'])->toBe(3) + ->and($breaker->alerts[0]->toArray(null)['title'])->toBe('tenant hit its daily SMS budget') + ->and($twoFa->decision)->toBe(SmsReservation::DECISION_ALLOWED) + ->and($guard->limiter()->attempts(SmsGuard::CACHE_PREFIX . 'platform:trusted:per_day'))->toBe(1) + ->and($otherTenant->decision)->toBe(SmsReservation::DECISION_ALLOWED); +}); + +test('sms guard alerts at the configured share of the daily budget without pausing', function () { + [$guard, $breaker] = sms_guard_test_setup([ + 'limits' => ['destination' => ['cooldown_seconds' => 0, 'per_hour' => null, 'per_day' => null], 'company' => ['per_minute' => 100, 'per_hour' => 100, 'per_day' => 4]], + 'breaker' => ['alert_percentages' => [50]], + ]); + + $guard->reserve(sms_guard_context('+447400100001')); + $guard->reserve(sms_guard_context('+447400100002')); + $guard->reserve(sms_guard_context('+447400100003')); + + expect($breaker->alerts)->toHaveCount(1) + ->and($breaker->alerts[0]->signal)->toBe(SmsBreaker::SIGNAL_BUDGET_THRESHOLD) + ->and($breaker->alerts[0]->data['percent'])->toBe(50) + ->and($breaker->pausedUntil('company:company-1'))->toBeNull(); +}); + +test('sms guard pauses a store that texts a burst of distinct numbers, and escalates repeat pauses', function () { + [$guard, $breaker] = sms_guard_test_setup([ + 'limits' => ['destination' => ['cooldown_seconds' => 0, 'per_hour' => null, 'per_day' => null]], + 'breaker' => ['distinct_destinations_per_10m' => 2, 'alert_percentages' => []], + ]); + + $guard->reserve(sms_guard_context('+447400100001')); + $guard->reserve(sms_guard_context('+447400100002')); + $refused = sms_guard_refusal($guard, sms_guard_context('+447400100003')); + $first = $breaker->pausedUntil('tenant:store:store-1'); + + // The company itself is not paused, so its other store keeps working. + session(['company' => 'company-1', 'storefront_store' => 'store-2']); + $sibling = $guard->reserve(sms_guard_context('+447400100007')); + + session(['company' => 'company-1', 'storefront_store' => 'store-1']); + $breaker->unpause('tenant:store:store-1'); + $guard->reserve(sms_guard_context('+447400100003')); + $second = $breaker->pausedUntil('tenant:store:store-1'); + + expect($refused->status())->toBe(423) + ->and($refused->context()['scope'])->toBe('tenant:store:store-1') + ->and($first['reason'])->toBe(SmsBreaker::SIGNAL_DISTINCT_DESTINATIONS) + ->and($first['until'])->not->toBeNull() + ->and($breaker->pausedUntil('company:company-1'))->toBeNull() + ->and($sibling->decision)->toBe(SmsReservation::DECISION_ALLOWED) + ->and($second)->not->toBeNull() + ->and($second['until'])->toBeNull() + ->and($second['reason'])->toContain('administrator must unpause') + ->and($breaker->alerts)->toHaveCount(1) + ->and($breaker->alerts[0]->signal)->toBe(SmsBreaker::SIGNAL_DISTINCT_DESTINATIONS); +}); + +test('sms guard in monitor mode lets everything through and records what it would have refused', function () { + [$guard, $breaker] = sms_guard_test_setup([ + 'mode' => 'monitor', + 'limits' => ['destination' => ['cooldown_seconds' => 60, 'per_hour' => null, 'per_day' => null]], + 'breaker' => ['alert_percentages' => [], 'distinct_destinations_per_10m' => 2], + ]); + + $invalid = $guard->reserve(sms_guard_context('+4471')); + $first = $guard->reserve(sms_guard_context('+447400100123')); + $cooldown = $guard->reserve(sms_guard_context('+447400100123')); + $entries = collect(app('log')->entries)->pluck(1)->all(); + + expect($invalid->decision)->toBe(SmsReservation::DECISION_MONITOR) + ->and($invalid->wouldBlock)->toBe('invalid_destination') + ->and($first->decision)->toBe(SmsReservation::DECISION_ALLOWED) + ->and($cooldown->decision)->toBe(SmsReservation::DECISION_MONITOR) + ->and($cooldown->wouldBlock)->toBe('cooldown') + ->and($entries)->toContain('sms.would_block') + ->and($entries)->not->toContain('sms.blocked') + ->and($breaker->pausedUntil('tenant:store:store-1'))->toBeNull() + ->and($breaker->pausedUntil('company:company-1'))->toBeNull() + ->and($breaker->alerts)->toHaveCount(1) + ->and($breaker->alerts[0]->data['mode'])->toBe('monitor') + ->and($breaker->alerts[0]->toArray(null)['lines'])->toContain('The guard is in monitor mode, so nothing was paused.'); +}); + +test('sms guard fails closed for public sends and open for trusted ones when the counters are unreachable', function () { + [$guard] = sms_guard_test_setup([], '/storefront/v1/customers/request-creation-code', new SmsGuardTestBrokenCache(new ArrayStore())); + + $public = sms_guard_refusal($guard, sms_guard_context('+447400100123')); + $trusted = $guard->reserve(sms_guard_context('+447400100123', '2fa')); + + [$consoleGuard] = sms_guard_test_setup([], '/int/v1/users/send-verification', new SmsGuardTestBrokenCache(new ArrayStore())); + $console = $consoleGuard->reserve(sms_guard_context('+447400100123', 'phone_verification')); + + [$monitorGuard] = sms_guard_test_setup(['mode' => 'monitor'], '/storefront/v1/customers/request-creation-code', new SmsGuardTestBrokenCache(new ArrayStore())); + $monitored = $monitorGuard->reserve(sms_guard_context('+447400100123')); + + expect($public->status())->toBe(503) + ->and($public->reason())->toBe(SmsBlockedException::REASON_UNAVAILABLE) + ->and($trusted->decision)->toBe(SmsReservation::DECISION_DEGRADED) + ->and($console->decision)->toBe(SmsReservation::DECISION_DEGRADED) + ->and($monitored->decision)->toBe(SmsReservation::DECISION_DEGRADED); +}); + +test('sms guard counters skip ip and tenant layers for trusted purposes and keep guard options from providers', function () { + [$guard] = sms_guard_test_setup(['limits' => ['tenant' => ['per_minute' => null, 'per_hour' => null, 'per_day' => 40]]]); + + $public = collect($guard->counters(sms_guard_context('+447400100123'), SmsPolicy::limitsFor(sms_guard_context('+447400100123')))); + $trusted = collect($guard->counters(sms_guard_context('+447400100123', '2fa'), SmsPolicy::limitsFor(sms_guard_context('+447400100123', '2fa')))); + + expect($public->pluck('scope')->unique()->values()->all())->toBe(['destination', 'ip', 'tenant', 'company', 'platform']) + ->and($public->firstWhere('scope', 'tenant')['key'])->toBe(SmsGuard::CACHE_PREFIX . 'tenant:store:store-1:per_day') + ->and($public->firstWhere('scope', 'platform')['key'])->toBe(SmsGuard::CACHE_PREFIX . 'platform:public:per_minute') + ->and($trusted->pluck('scope')->unique()->values()->all())->toBe(['destination', 'platform']) + ->and($trusted->firstWhere('scope', 'platform')['key'])->toBe(SmsGuard::CACHE_PREFIX . 'platform:trusted:per_minute') + ->and(SmsGuard::providerOptions(['purpose' => 'x', 'sms_reservation' => 'r', 'twilioParams' => ['from' => 'ACME'], 'from' => 'ACME']))->toBe(['twilioParams' => ['from' => 'ACME'], 'from' => 'ACME']); +}); + +test('sms guard settle logs the outcome once per reservation', function () { + [$guard] = sms_guard_test_setup(); + + $reservation = $guard->reserve(sms_guard_context('+447400100123')); + $guard->settle($reservation, true, ['provider' => 'twilio', 'message_id' => 'SM1']); + $guard->settle($reservation, false, ['provider' => 'twilio', 'error' => 'ignored, already settled']); + + $failed = $guard->reserve(sms_guard_context('+447400100124')); + $guard->settle($failed, false, ['provider' => 'twilio', 'error' => 'carrier refused']); + + $bypassed = new SmsReservation(sms_guard_context('+447400100125'), SmsReservation::DECISION_BYPASSED); + $guard->settle($bypassed, true); + + $entries = collect(app('log')->entries); + + expect($entries->where(1, 'sms.sent'))->toHaveCount(1) + ->and($entries->where(1, 'sms.sent')->first()[2]['message_id'])->toBe('SM1') + ->and($entries->where(1, 'sms.failed'))->toHaveCount(1) + ->and($entries->where(1, 'sms.failed')->first()[2]['error'])->toBe('carrier refused') + ->and($reservation->settled)->toBeTrue() + ->and($bypassed->settled)->toBeTrue(); +}); + +test('sms guard make returns a bound instance when the container has one', function () { + [$guard] = sms_guard_test_setup(); + app()->instance(SmsGuard::class, $guard); + + expect(SmsGuard::make())->toBe($guard); + + app()->forgetInstance(SmsGuard::class); + + expect(SmsGuard::make())->not->toBe($guard); +}); diff --git a/tests/Unit/Support/Sms/SmsPolicyTest.php b/tests/Unit/Support/Sms/SmsPolicyTest.php new file mode 100644 index 00000000..3953576d --- /dev/null +++ b/tests/Unit/Support/Sms/SmsPolicyTest.php @@ -0,0 +1,255 @@ + true, + 'mode' => 'enforce', + 'alert_emails' => 'ops@example.test, OPS@example.test, not-an-email', + 'validation' => [ + 'enabled' => true, + 'allowed_types' => ['mobile', 'fixed_line_or_mobile'], + 'blocked_countries' => 'kp, ir', + 'blocked_prefixes' => '+881,+882', + ], + 'limits' => [ + 'destination' => ['cooldown_seconds' => 60, 'per_hour' => 3, 'per_day' => 5], + 'ip' => ['per_15_minutes' => 5, 'per_day' => 20], + 'company' => ['per_minute' => 10, 'per_hour' => 60, 'per_day' => 200], + 'tenant' => ['per_minute' => null, 'per_hour' => null, 'per_day' => null], + 'probation' => ['days' => 7, 'per_day' => 20], + 'platform' => [ + 'public' => ['per_minute' => 100, 'per_hour' => 1500, 'per_day' => 10000], + 'trusted' => ['per_minute' => 100, 'per_hour' => 1500, 'per_day' => 10000], + ], + ], + 'breaker' => [ + 'pause_minutes' => 60, + 'alert_percentages' => [50, 80], + 'distinct_destinations_per_10m' => 25, + 'auto_pauses_before_manual' => 2, + ], + 'purposes' => [ + '2fa' => ['trusted' => true, 'destination' => ['cooldown_seconds' => 20, 'per_hour' => 10]], + 'storefront_login' => ['destination' => ['cooldown_seconds' => 30]], + ], + ], $overrides); +} + +function sms_policy_database(array $guard = []): Capsule +{ + EloquentModel::clearBootedModels(); + session()->flush(); + + $connection = [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]; + + $container = bind_test_container([ + 'sms.guard' => sms_policy_config($guard), + 'database.default' => 'mysql', + 'database.connections.mysql' => $connection, + 'fleetbase.connection.db' => 'mysql', + ]); + $container->instance('cache', new CacheRepository(new ArrayStore())); + Facade::clearResolvedInstances(); + + $capsule = new Capsule($container); + $capsule->addConnection($connection, 'mysql'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + $capsule->getDatabaseManager()->setDefaultConnection('mysql'); + $container->instance('db', $capsule->getDatabaseManager()); + Facade::clearResolvedInstance('db'); + + $schema = $capsule->getConnection('mysql')->getSchemaBuilder(); + $schema->create('settings', function ($table) { + $table->increments('id'); + $table->string('key')->unique(); + $table->text('value')->nullable(); + }); + $schema->create('companies', function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable(); + $table->string('name')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + $schema->create('users', function ($table) { + $table->string('uuid')->primary(); + $table->string('company_uuid')->nullable(); + $table->string('type')->nullable(); + $table->string('name')->nullable(); + $table->string('email')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + $db = $capsule->getConnection('mysql'); + $db->table('companies')->insert([ + ['uuid' => 'company-old', 'public_id' => 'company_old', 'name' => 'Old Co', 'created_at' => Carbon::now()->subDays(30), 'updated_at' => Carbon::now()], + ['uuid' => 'company-new', 'public_id' => 'company_new', 'name' => 'New Co', 'created_at' => Carbon::now()->subDay(), 'updated_at' => Carbon::now()], + ]); + $db->table('users')->insert([ + ['uuid' => 'admin-1', 'type' => 'admin', 'name' => 'Root', 'email' => 'Root@example.test'], + ['uuid' => 'admin-2', 'type' => 'admin', 'name' => 'Deleted', 'email' => 'gone@example.test', 'deleted_at' => Carbon::now()], + ['uuid' => 'user-1', 'type' => 'user', 'name' => 'Member', 'email' => 'member@example.test'], + ]); + + return $capsule; +} + +afterEach(function () { + Carbon::setTestNow(); + session()->flush(); + EloquentModel::unsetConnectionResolver(); + EloquentModel::clearBootedModels(); + Facade::clearResolvedInstances(); +}); + +test('sms policy defaults are normalised from the configuration', function () { + bind_test_container(['sms.guard' => sms_policy_config(['mode' => 'Observe', 'breaker' => ['alert_percentages' => '50, 80, 100, 0']])]); + + $defaults = SmsPolicy::defaults(); + + expect($defaults['enabled'])->toBeTrue() + ->and($defaults['mode'])->toBe('enforce') + ->and($defaults['alert_emails'])->toBe(['ops@example.test']) + ->and($defaults['validation']['allowed_types'])->toBe(['MOBILE', 'FIXED_LINE_OR_MOBILE']) + ->and($defaults['validation']['blocked_countries'])->toBe(['KP', 'IR']) + ->and($defaults['validation']['blocked_prefixes'])->toBe(['+881', '+882']) + ->and($defaults['limits']['company'])->toBe(['per_minute' => 10, 'per_hour' => 60, 'per_day' => 200]) + ->and($defaults['limits']['tenant'])->toBe(['per_minute' => null, 'per_hour' => null, 'per_day' => null]) + ->and($defaults['breaker']['alert_percentages'])->toBe([50, 80]) + ->and($defaults['purposes']['2fa'])->toBe(['trusted' => true, 'destination' => ['cooldown_seconds' => 20, 'per_hour' => 10, 'per_day' => null]]) + ->and($defaults['overrides'])->toBe([]) + ->and($defaults['pauses'])->toBe([]); +}); + +test('sms policy falls back to the defaults when there is no database or cache', function () { + bind_test_container(['sms.guard' => sms_policy_config()]); + Facade::clearResolvedInstances(); + + expect(SmsPolicy::stored())->toBe([]) + ->and(SmsPolicy::settings()['mode'])->toBe('enforce') + ->and(SmsPolicy::isEnabled())->toBeTrue() + ->and(SmsPolicy::mode())->toBe('enforce') + ->and(SmsPolicy::isOnProbation('company-new', 7))->toBeFalse() + ->and(SmsPolicy::alertRecipients())->toBe(['ops@example.test']); +}); + +test('sms policy applies the stored administrator override and company exceptions', function () { + sms_policy_database(); + + Setting::configureSystem(SmsPolicy::SETTING_KEY, [ + 'mode' => 'monitor', + 'limits' => ['company' => ['per_day' => 50]], + 'overrides' => [ + ['company_uuid' => 'company-old', 'per_day' => 2000, 'note' => 'large client'], + ['company_uuid' => 'company-new', 'unlimited' => true], + ['company_uuid' => '', 'per_day' => 1], + ], + 'pauses' => ['company:company-old' => ['until' => null, 'reason' => 'manual', 'auto' => false]], + ]); + SmsPolicy::flush(); + + $settings = SmsPolicy::settings(); + $raised = SmsPolicy::limitsFor(new SmsContext('storefront_create_customer', '+447400100123', 'company-old', 'store', 'store-1', 'storefront', '203.0.113.9'), $settings); + $plain = SmsPolicy::limitsFor(new SmsContext('storefront_create_customer', '+447400100123', 'company-other'), $settings); + $noLimit = SmsPolicy::limitsFor(new SmsContext('storefront_create_customer', '+447400100123', 'company-new'), $settings); + + expect($settings['mode'])->toBe('monitor') + ->and($settings['limits']['company']['per_day'])->toBe(50) + ->and($settings['limits']['company']['per_hour'])->toBe(60) + ->and($settings['overrides'])->toHaveCount(2) + ->and($settings['pauses']['company:company-old']['reason'])->toBe('manual') + ->and(SmsPolicy::overrideFor('company-old', $settings)['note'])->toBe('large client') + ->and(SmsPolicy::overrideFor(null, $settings))->toBeNull() + ->and($raised['company']['per_day'])->toBe(2000) + ->and($raised['company']['per_hour'])->toBe(60) + ->and($raised['tenant'])->toBeNull() + ->and($raised['probation'])->toBeFalse() + ->and($plain['company']['per_day'])->toBe(50) + ->and($noLimit['company'])->toBeNull() + ->and($noLimit['company_unlimited'])->toBeTrue(); +}); + +test('sms policy update keeps the rest of the stored override and reset drops it', function () { + sms_policy_database(); + + SmsPolicy::update(['mode' => 'monitor']); + SmsPolicy::update(['limits' => ['company' => ['per_day' => 75]]]); + + $settings = SmsPolicy::settings(); + expect($settings['mode'])->toBe('monitor') + ->and($settings['limits']['company']['per_day'])->toBe(75) + ->and(SmsPolicy::stored())->toBe(['mode' => 'monitor', 'limits' => ['company' => ['per_day' => 75]]]); + + $reset = SmsPolicy::reset(); + expect($reset['mode'])->toBe('enforce') + ->and($reset['limits']['company']['per_day'])->toBe(200) + ->and(SmsPolicy::stored())->toBe([]); +}); + +test('sms policy puts young companies on probation and exempts trusted purposes from the company budget', function () { + sms_policy_database(); + + $young = SmsPolicy::limitsFor(new SmsContext('storefront_create_customer', '+447400100123', 'company-new', 'store', 'store-1', 'storefront', '203.0.113.9')); + $old = SmsPolicy::limitsFor(new SmsContext('storefront_create_customer', '+447400100123', 'company-old', null, null, 'api', '203.0.113.9')); + $trusted = SmsPolicy::limitsFor(new SmsContext('2fa', '+447400100123', 'company-new', 'store', 'store-1', 'console', '203.0.113.9')); + $login = SmsPolicy::limitsFor(new SmsContext('storefront_login', '+447400100123', 'company-old')); + + expect($young['probation'])->toBeTrue() + ->and($young['company']['per_day'])->toBe(20) + ->and($young['bucket'])->toBe('public') + ->and($young['ip'])->toBe(['per_15_minutes' => 5, 'per_day' => 20]) + ->and($old['probation'])->toBeFalse() + ->and($old['company']['per_day'])->toBe(200) + ->and($trusted['trusted'])->toBeTrue() + ->and($trusted['bucket'])->toBe('trusted') + ->and($trusted['company'])->toBeNull() + ->and($trusted['ip'])->toBeNull() + ->and($trusted['tenant'])->toBeNull() + ->and($trusted['destination'])->toBe(['cooldown_seconds' => 20, 'per_hour' => 10, 'per_day' => 5]) + ->and($login['destination']['cooldown_seconds'])->toBe(30) + ->and($login['destination']['per_hour'])->toBe(3) + ->and(SmsPolicy::isTrusted('2fa'))->toBeTrue() + ->and(SmsPolicy::isTrusted('storefront_login'))->toBeFalse() + ->and(SmsPolicy::isOnProbation(null, 7))->toBeFalse() + ->and(SmsPolicy::isOnProbation('company-new', 0))->toBeFalse() + ->and(SmsPolicy::isOnProbation('company-missing', 7))->toBeFalse(); +}); + +test('sms policy alerts go to the configured addresses and every live system administrator', function () { + sms_policy_database(); + + expect(SmsPolicy::alertRecipients())->toBe(['ops@example.test', 'root@example.test']) + ->and(SmsPolicy::adminUsers()->pluck('uuid')->all())->toBe(['admin-1']); +}); + +test('sms policy merge replaces lists and merges associative arrays', function () { + $merged = SmsPolicy::merge( + ['validation' => ['allowed_types' => ['MOBILE', 'FIXED_LINE_OR_MOBILE'], 'enabled' => true], 'mode' => 'enforce'], + ['validation' => ['allowed_types' => ['MOBILE']], 'mode' => 'monitor', 'extra' => ['a' => 1]] + ); + + expect($merged)->toBe([ + 'validation' => ['allowed_types' => ['MOBILE'], 'enabled' => true], + 'mode' => 'monitor', + 'extra' => ['a' => 1], + ]); +}); From 54b281bb7e449ed0a79010f74408858f1903701d Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 17:07:50 +0800 Subject: [PATCH 43/51] fix(events): read the snake case foreign key for relationship uuid channels addRelationshipSpecificChannels checked isset($model->driver_assigned_uuid) but then built the channel from $model->driverAssigned_uuid, an attribute that never exists, so every order lifecycle event broadcast on a dead 'driverAssigned.' channel instead of 'driverAssigned.{driver uuid}'. Read the same snake case key it checks. --- src/Events/ResourceLifecycleEvent.php | 6 ++- tests/Unit/EventsAndExceptionsTest.php | 51 +++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/Events/ResourceLifecycleEvent.php b/src/Events/ResourceLifecycleEvent.php index cddc2f8c..5a9e444c 100644 --- a/src/Events/ResourceLifecycleEvent.php +++ b/src/Events/ResourceLifecycleEvent.php @@ -225,8 +225,10 @@ protected function addRelationshipSpecificChannels($model, &$channels) { $relationships = ['driverAssigned', 'customer', 'facilitator', 'vendor']; foreach ($relationships as $relationship) { - if ($model && isset($model->{Str::snake($relationship) . '_uuid'})) { - $channels[] = new Channel($relationship . '.' . $model->{$relationship . '_uuid'}); + // Foreign keys are snake case (`driver_assigned_uuid`), the relationship names are camel case. + $foreignKey = Str::snake($relationship) . '_uuid'; + if ($model && isset($model->{$foreignKey})) { + $channels[] = new Channel($relationship . '.' . $model->{$foreignKey}); } if ($model && isset($model->{$relationship})) { $channels[] = new Channel($relationship . '.' . $model->{$relationship}->public_id); diff --git a/tests/Unit/EventsAndExceptionsTest.php b/tests/Unit/EventsAndExceptionsTest.php index e4ccefee..4a8d39ab 100644 --- a/tests/Unit/EventsAndExceptionsTest.php +++ b/tests/Unit/EventsAndExceptionsTest.php @@ -657,7 +657,6 @@ public function toArray($request): array $channel->setRelation('driverAssigned', $driver); $channel->setAttribute('customer_uuid', 'customer-uuid'); $channel->setAttribute('driver_assigned_uuid', 'driver-assigned-uuid'); - $channel->setAttribute('driverAssigned_uuid', 'driver-assigned-uuid'); $event = EventsAndExceptionsLifecycleEvent::fake([ 'modelName' => 'chat_channel', @@ -699,6 +698,56 @@ public function toArray($request): array ->and($channels)->toContain('chat.33333333-3333-4333-8333-333333333333'); }); +test('resource lifecycle events read relationship uuid channels from the snake case foreign key', function () { + bind_test_container(); + session()->flush(); + session(['company' => 'session-company', 'user' => 'session-user']); + + // Only the real foreign key columns are set, no relations are loaded: the uuid channel must come + // from `driver_assigned_uuid`, not from a `driverAssigned_uuid` attribute that never exists. + $channel = new ChatChannel(); + $channel->setRawAttributes([ + 'uuid' => '44444444-4444-4444-8444-444444444444', + 'public_id' => 'chat_1111111', + 'company_uuid' => 'model-company', + 'created_by_uuid' => 'creator-user', + 'driver_assigned_uuid' => 'driver-uuid-only', + 'customer_uuid' => 'customer-uuid-only', + ], true); + $channel->setRelation('company', null); + + $event = EventsAndExceptionsLifecycleEvent::fake([ + 'modelName' => 'chat_channel', + 'modelClassNamespace' => ChatChannel::class, + 'modelClassName' => 'ChatChannel', + 'modelHumanName' => 'chat channel', + 'modelRecordName' => null, + 'modelUuid' => '44444444-4444-4444-8444-444444444444', + 'namespace' => '\\Fleetbase', + 'version' => 1, + 'eventName' => 'updated', + 'sentAt' => '2026-07-18 12:00:00', + 'eventId' => 'event_driver_channel', + 'apiVersion' => 'v1', + 'requestMethod' => 'PUT', + 'apiCredential' => 'console', + 'apiSecret' => 'internal', + 'apiKey' => null, + 'apiEnvironment' => 'live', + 'isSandbox' => false, + 'data' => [], + 'userSession' => 'session-user', + 'companySession' => 'session-company', + ], $channel); + + $channels = array_map(fn ($channel) => (string) $channel, $event->broadcastOn()); + + expect($channels)->toContain('driverAssigned.driver-uuid-only') + ->and($channels)->toContain('customer.customer-uuid-only') + ->and($channels)->not->toContain('driverAssigned.') + ->and($channels)->not->toContain('customer.'); +}); + test('resource lifecycle events prefer webhook payloads when resources provide them', function () { bind_test_container(['api.version' => 'v1']); From ec5efe4ac2ae68cb74a21f8873c5e20ad2d67e0c Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 17:08:30 +0800 Subject: [PATCH 44/51] feat(comments): internal and customer visibility on comments, broadcast comment.created Comments carry meta.visibility = internal | customer (a 'visibility' shorthand is accepted on create). When none is given: replies follow their parent, comments on orders are internal when written through an internal (console) route and customer from anywhere else, and comments on other subjects keep no visibility, which stays visible, so existing data and callers are unaffected. - Comment::scopeVisibleToCustomers() (everything but internal) and scopeInternal(), getVisibility(), isInternal(), isVisibleToCustomers() - the public v1 comments API excludes internal comments from the listing (CommentFilter queryForPublic) and find, accepts an optional visibility on create, and the Comment resource hides internal replies outside internal requests and exposes a top level 'visibility' - new CommentCreated event broadcasts 'comment.created' with the comment resource on company.{company_uuid} for every comment and, for customer visible comments only, on the subject's channels ({subject type}.{public_id} and {subject type}.{uuid}, e.g. order.order_x); a broadcast failure is logged and never fails the comment --- src/Events/CommentCreated.php | 132 +++++++++++ .../Controllers/Api/v1/CommentController.php | 9 +- src/Http/Filter/CommentFilter.php | 3 + src/Http/Requests/CreateCommentRequest.php | 2 + src/Http/Resources/Comment.php | 16 +- src/Models/Comment.php | 156 ++++++++++++ tests/Fixtures/Models/Order.php | 18 ++ tests/Unit/Http/CommentControllerTest.php | 223 ++++++++++++++++++ 8 files changed, 556 insertions(+), 3 deletions(-) create mode 100644 src/Events/CommentCreated.php create mode 100644 tests/Fixtures/Models/Order.php diff --git a/src/Events/CommentCreated.php b/src/Events/CommentCreated.php new file mode 100644 index 00000000..269e2a62 --- /dev/null +++ b/src/Events/CommentCreated.php @@ -0,0 +1,132 @@ +eventId = uniqid('event_'); + $this->createdAt = Carbon::now(); + $this->comment = $comment; + } + + /** + * The event's broadcast name. + */ + public function broadcastAs(): string + { + return 'comment.created'; + } + + /** + * Get the channels the event should broadcast on. + * + * @return array + */ + public function broadcastOn(): array + { + $channels = []; + + if ($this->comment->company_uuid) { + $channels[] = new Channel('company.' . $this->comment->company_uuid); + } + + if ($this->comment->isVisibleToCustomers()) { + foreach ($this->subjectChannelNames() as $name) { + $channels[] = new Channel($name); + } + } + + return $channels; + } + + /** + * The subject's channels: `{subject type}.{public_id}` and `{subject type}.{uuid}`, the same names + * the subject's own lifecycle events use (`order.order_x`). + * + * @return array + */ + public function subjectChannelNames(): array + { + $subjectType = $this->comment->subject_type; + if (!$subjectType || !$this->comment->subject_uuid) { + return []; + } + + $prefix = Str::snake(class_basename(ltrim($subjectType, '\\'))); + $subject = $this->resolveSubject(); + $publicId = $subject ? data_get($subject, 'public_id') : null; + if (!$publicId) { + return []; + } + + return [$prefix . '.' . $publicId, $prefix . '.' . $this->comment->subject_uuid]; + } + + /** + * Resolve the commented subject, ignoring global scopes and failures (the broadcast is best effort). + */ + protected function resolveSubject(): mixed + { + try { + if ($this->comment->relationLoaded('subject')) { + return $this->comment->getRelation('subject'); + } + + $subjectType = ltrim($this->comment->subject_type, '\\'); + if (!class_exists($subjectType)) { + return null; + } + + return (new $subjectType())->newQuery()->withoutGlobalScopes()->where('uuid', $this->comment->subject_uuid)->first(); + } catch (\Throwable $e) { + return null; + } + } + + /** + * Get the data to broadcast. + */ + public function broadcastWith(): array + { + $resource = new CommentResource($this->comment); + + return [ + 'id' => $this->eventId, + 'event' => $this->broadcastAs(), + 'created_at' => $this->createdAt->toDateTimeString(), + 'subject_id' => data_get($this->resolveSubject(), 'public_id'), + 'data' => $resource->resolveFor(ResourceTransformerContext::BROADCAST, request()), + ]; + } +} diff --git a/src/Http/Controllers/Api/v1/CommentController.php b/src/Http/Controllers/Api/v1/CommentController.php index 24fd93ce..88cfbdec 100644 --- a/src/Http/Controllers/Api/v1/CommentController.php +++ b/src/Http/Controllers/Api/v1/CommentController.php @@ -35,6 +35,11 @@ public function create(CreateCommentRequest $request) 'content' => $content, ]; + // Optional `internal` | `customer`; unset falls back to the subject's default visibility. + if (in_array($request->input('visibility'), Comment::VISIBILITIES, true)) { + $data['visibility'] = $request->input('visibility'); + } + // Resolve the parent $parentComment = null; if ($parent) { @@ -144,9 +149,9 @@ public function query(Request $request) */ public function find($id) { - // find for the Comment + // find for the Comment, internal notes are not exposed on the public API try { - $comment = Comment::findRecordOrFail($id); + $comment = Comment::findRecordOrFail($id, [], ['*'], fn ($query) => $query->visibleToCustomers()); } catch (ModelNotFoundException $exception) { return response()->apiError('Comment resource not found.', 404); } catch (\Throwable $e) { diff --git a/src/Http/Filter/CommentFilter.php b/src/Http/Filter/CommentFilter.php index da327424..153396e6 100644 --- a/src/Http/Filter/CommentFilter.php +++ b/src/Http/Filter/CommentFilter.php @@ -15,6 +15,9 @@ public function queryForInternal() public function queryForPublic() { $this->queryForInternal(); + + // Internal notes (`meta.visibility = internal`) never leave the console. + $this->builder->visibleToCustomers(); } public function subject(string $id) diff --git a/src/Http/Requests/CreateCommentRequest.php b/src/Http/Requests/CreateCommentRequest.php index 6d16696e..00497754 100644 --- a/src/Http/Requests/CreateCommentRequest.php +++ b/src/Http/Requests/CreateCommentRequest.php @@ -2,6 +2,7 @@ namespace Fleetbase\Http\Requests; +use Fleetbase\Models\Comment; use Illuminate\Validation\Rule; class CreateCommentRequest extends FleetbaseRequest @@ -43,6 +44,7 @@ public function rules() return !$this->filled('parent') && !$this->filled('subject') && !$this->filled('subject_type') && !$this->filled('subject_id') && !$this->filled('subject_uuid') && $this->isMethod('POST'); })], 'content' => ['required'], + 'visibility' => ['nullable', Rule::in(Comment::VISIBILITIES)], ]; } } diff --git a/src/Http/Resources/Comment.php b/src/Http/Resources/Comment.php index 0facfa62..8db9e54e 100644 --- a/src/Http/Resources/Comment.php +++ b/src/Http/Resources/Comment.php @@ -28,12 +28,26 @@ public function toArray($request) 'content' => $this->content, 'tags' => $this->tags, 'meta' => data_get($this, 'meta', Utils::createObject()), + 'visibility' => method_exists($this->resource, 'getVisibility') ? $this->resource->getVisibility() : null, 'author' => new Author($this->author), - 'replies' => static::collection($this->replies), + 'replies' => static::collection($this->visibleReplies()), 'editable' => $request->hasSession() && session('user') === $this->author_uuid, 'updated_at' => $this->updated_at, 'created_at' => $this->created_at, 'deleted_at' => $this->deleted_at, ]; } + + /** + * Replies to show: all of them for the console, only customer visible ones anywhere else. + */ + protected function visibleReplies(): mixed + { + $replies = $this->replies; + if (!$replies || Http::isInternalRequest()) { + return $replies; + } + + return $replies->filter(fn ($reply) => !method_exists($reply, 'isVisibleToCustomers') || $reply->isVisibleToCustomers())->values(); + } } diff --git a/src/Models/Comment.php b/src/Models/Comment.php index f6f9c333..1c98a0c3 100644 --- a/src/Models/Comment.php +++ b/src/Models/Comment.php @@ -4,9 +4,15 @@ use Fleetbase\Casts\Json; use Fleetbase\Casts\PolymorphicType; +use Fleetbase\Events\CommentCreated; +use Fleetbase\Support\Http; use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasPublicId; use Fleetbase\Traits\HasUuid; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; class Comment extends Model { @@ -14,6 +20,30 @@ class Comment extends Model use HasPublicId; use HasApiModelBehavior; + /** + * Visible to the company's own users only (console notes). + */ + public const VISIBILITY_INTERNAL = 'internal'; + + /** + * Visible to the customer as well (customer facing APIs and channels). + */ + public const VISIBILITY_CUSTOMER = 'customer'; + + /** + * The values `meta.visibility` accepts. + */ + public const VISIBILITIES = [self::VISIBILITY_INTERNAL, self::VISIBILITY_CUSTOMER]; + + /** + * Subject types (snake cased class basenames) whose comments default to `internal` when they are + * written through the internal (console) API, and to `customer` otherwise. Comments on any other + * subject keep no visibility unless one is given, which is treated as visible. + * + * @var array + */ + public static array $visibilityAwareSubjectTypes = ['order']; + /** * The database table used by the model. * @@ -74,6 +104,118 @@ class Comment extends Model 'subject_type' => PolymorphicType::class, ]; + /** + * Default the comment visibility and broadcast new comments. + */ + protected static function booted(): void + { + static::creating(function (Comment $comment) { + $comment->applyDefaultVisibility(); + }); + + static::created(function (Comment $comment) { + // A broadcast failure (socket server down, missing broadcaster) must never fail the comment. + try { + Event::dispatch(new CommentCreated($comment)); + } catch (\Throwable $e) { + Log::warning('Unable to broadcast comment.created', ['comment' => $comment->public_id, 'error' => $e->getMessage()]); + } + }); + } + + /** + * Only comments a customer may see: everything except `internal` ones. Comments with no + * visibility (written before visibility existed, or on subjects without a default) stay visible. + */ + public function scopeVisibleToCustomers(Builder $query): Builder + { + return $query->where(function (Builder $query) { + $query->whereNull('meta') + ->orWhereNull('meta->visibility') + ->orWhere('meta->visibility', '!=', self::VISIBILITY_INTERNAL); + }); + } + + /** + * Only `internal` comments. + */ + public function scopeInternal(Builder $query): Builder + { + return $query->where('meta->visibility', self::VISIBILITY_INTERNAL); + } + + /** + * The effective visibility: `internal` when marked so, `customer` otherwise. + */ + public function getVisibility(): string + { + return data_get($this->meta, 'visibility') === self::VISIBILITY_INTERNAL ? self::VISIBILITY_INTERNAL : self::VISIBILITY_CUSTOMER; + } + + public function isInternal(): bool + { + return $this->getVisibility() === self::VISIBILITY_INTERNAL; + } + + public function isVisibleToCustomers(): bool + { + return !$this->isInternal(); + } + + /** + * Whether a subject type (class name or short type) defaults its comments' visibility. + */ + public static function isVisibilityAwareSubjectType(?string $subjectType): bool + { + if (!$subjectType) { + return false; + } + + return in_array(Str::snake(class_basename(ltrim($subjectType, '\\'))), static::$visibilityAwareSubjectTypes, true); + } + + /** + * Fills `meta.visibility` when it is missing or not a known value: replies follow their parent, + * comments on visibility aware subjects (orders) are `internal` from the console and `customer` + * from everywhere else, and other comments are left without one (visible). + */ + public function applyDefaultVisibility(): void + { + $meta = is_array($this->meta) ? $this->meta : []; + $visibility = $meta['visibility'] ?? null; + + if (in_array($visibility, self::VISIBILITIES, true)) { + return; + } + + $default = null; + if ($this->parent_comment_uuid) { + $parentMeta = static::query() + ->withoutGlobalScopes() + ->where('uuid', $this->parent_comment_uuid) + ->value('meta'); + $default = data_get(Json::decode($parentMeta), 'visibility'); + } + + if (!in_array($default, self::VISIBILITIES, true)) { + $default = static::isVisibilityAwareSubjectType($this->subject_type) + ? (Http::isInternalRequest() ? self::VISIBILITY_INTERNAL : self::VISIBILITY_CUSTOMER) + : null; + } + + if ($default === null && $visibility === null) { + // Nothing to set: leave meta exactly as given (a null column stays null). + return; + } + + unset($meta['visibility']); + if ($default !== null) { + $meta['visibility'] = $default; + } + + $this->meta = $meta; + } + /** * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ @@ -122,6 +264,20 @@ public static function publish(array $attributes): Comment // If replies or parent is set somehow unset it unset($attributes['replies'], $attributes['parent']); + // `visibility` is accepted as a shorthand for `meta.visibility`, it is not a column. + if (array_key_exists('visibility', $attributes)) { + $visibility = $attributes['visibility']; + unset($attributes['visibility']); + + if (in_array($visibility, self::VISIBILITIES, true)) { + $meta = $attributes['meta'] ?? []; + $meta = is_string($meta) ? (json_decode($meta, true) ?: []) : (array) $meta; + + $meta['visibility'] = $visibility; + $attributes['meta'] = $meta; + } + } + // set author if unset if (empty($attributes['author_uuid'])) { $attributes['author_uuid'] = session('user'); diff --git a/tests/Fixtures/Models/Order.php b/tests/Fixtures/Models/Order.php new file mode 100644 index 00000000..569bb607 --- /dev/null +++ b/tests/Fixtures/Models/Order.php @@ -0,0 +1,18 @@ +and($withoutParent->collection->pluck('public_id')->all())->toEqualCanonicalizing(['comment_root', 'comment_other_subject']) ->and($parentScoped->collection->pluck('public_id')->all())->toBe(['comment_reply']); }); + +class CommentVisibilityRoute +{ + public object $controller; + + public function __construct(private string $uri = 'int/v1/comments', private string $method = 'create') + { + $this->controller = new class { + }; + } + + public function getAction(?string $key = null): mixed + { + $action = [ + 'controller' => CommentController::class . '@' . $this->method, + ]; + + return $key ? $action[$key] ?? null : $action; + } + + public function getActionMethod(): string + { + return $this->method; + } + + public function uri(): string + { + return $this->uri; + } +} + +function comment_visibility_database(): Capsule +{ + $capsule = comment_controller_database(); + $capsule->getConnection('mysql')->getSchemaBuilder()->create('orders', function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable()->index(); + $table->string('company_uuid')->nullable()->index(); + $table->timestamps(); + }); + $capsule->getConnection('mysql')->table('orders')->insert([ + ['uuid' => 'order-uuid-1', 'public_id' => 'order_1234567', 'company_uuid' => 'company-1'], + ]); + + return $capsule; +} + +function comment_visibility_use_route(string $uri): void +{ + $request = Request::create('/' . $uri, 'POST'); + $request->setRouteResolver(fn () => new CommentVisibilityRoute($uri)); + Container::getInstance()->instance('request', $request); +} + +function comment_visibility_publish(array $attributes = []): Comment +{ + return Comment::publish(array_merge([ + 'subject_uuid' => 'order-uuid-1', + 'subject_type' => Fleetbase\Tests\Fixtures\Models\Order::class, + 'content' => 'Leave at the side door.', + ], $attributes)); +} + +test('comments on orders default to internal from the console and customer from everywhere else', function () { + comment_visibility_database(); + + comment_visibility_use_route('int/v1/comments'); + $console = comment_visibility_publish(); + + comment_visibility_use_route('storefront/int/v1/orders/order_1234567/notes'); + $storefrontConsole = comment_visibility_publish(); + + comment_visibility_use_route('v1/comments'); + $publicApi = comment_visibility_publish(); + + comment_visibility_use_route('int/v1/comments'); + $explicit = comment_visibility_publish(['visibility' => 'customer']); + $viaMeta = comment_visibility_publish(['meta' => ['visibility' => 'customer', 'pinned' => true]]); + $invalid = comment_visibility_publish(['meta' => ['visibility' => 'everyone']]); + + expect(Comment::find($console->uuid)->meta)->toBe(['visibility' => 'internal']) + ->and($console->isInternal())->toBeTrue() + ->and($storefrontConsole->getVisibility())->toBe('internal') + ->and(Comment::find($publicApi->uuid)->meta)->toBe(['visibility' => 'customer']) + ->and($publicApi->isVisibleToCustomers())->toBeTrue() + ->and(Comment::find($explicit->uuid)->meta)->toBe(['visibility' => 'customer']) + ->and(Comment::find($viaMeta->uuid)->meta)->toBe(['visibility' => 'customer', 'pinned' => true]) + ->and(Comment::find($invalid->uuid)->meta)->toBe(['visibility' => 'internal']); +}); + +test('comments on other subjects keep no visibility unless given and replies follow their parent', function () { + $capsule = comment_visibility_database(); + + comment_visibility_use_route('int/v1/comments'); + $userComment = Comment::publish([ + 'subject_uuid' => 'user-subject', + 'subject_type' => User::class, + 'content' => 'Note on a user', + ]); + $internalRoot = comment_visibility_publish(); + $reply = comment_visibility_publish(['parent_comment_uuid' => $internalRoot->uuid]); + + comment_visibility_use_route('v1/comments'); + $publicReplyToInternal = comment_visibility_publish(['parent_comment_uuid' => $internalRoot->uuid]); + + expect($capsule->getConnection('mysql')->table('comments')->where('uuid', $userComment->uuid)->value('meta'))->toBeNull() + ->and($userComment->getVisibility())->toBe('customer') + ->and($reply->getVisibility())->toBe('internal') + ->and($publicReplyToInternal->getVisibility())->toBe('internal'); +}); + +test('visible to customers scope excludes only internal comments', function () { + $capsule = comment_visibility_database(); + $now = '2026-07-18 00:10:00'; + $capsule->getConnection('mysql')->table('comments')->insert([ + ['id' => 10, 'uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'public_id' => 'comment_internal', 'company_uuid' => 'company-1', 'subject_uuid' => 'order-uuid-1', 'subject_type' => Fleetbase\Tests\Fixtures\Models\Order::class, 'author_uuid' => 'user-author', 'content' => 'Internal', 'meta' => '{"visibility":"internal"}', 'created_at' => $now, 'updated_at' => $now], + ['id' => 11, 'uuid' => 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'public_id' => 'comment_customer', 'company_uuid' => 'company-1', 'subject_uuid' => 'order-uuid-1', 'subject_type' => Fleetbase\Tests\Fixtures\Models\Order::class, 'author_uuid' => 'user-author', 'content' => 'Customer', 'meta' => '{"visibility":"customer"}', 'created_at' => $now, 'updated_at' => $now], + ['id' => 12, 'uuid' => 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', 'public_id' => 'comment_other_meta', 'company_uuid' => 'company-1', 'subject_uuid' => 'order-uuid-1', 'subject_type' => Fleetbase\Tests\Fixtures\Models\Order::class, 'author_uuid' => 'user-author', 'content' => 'Other meta', 'meta' => '{"pinned":true}', 'created_at' => $now, 'updated_at' => $now], + ['id' => 13, 'uuid' => 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', 'public_id' => 'comment_json_null', 'company_uuid' => 'company-1', 'subject_uuid' => 'order-uuid-1', 'subject_type' => Fleetbase\Tests\Fixtures\Models\Order::class, 'author_uuid' => 'user-author', 'content' => 'Json null', 'meta' => 'null', 'created_at' => $now, 'updated_at' => $now], + ]); + + $visible = Comment::query()->without(['author', 'replies'])->visibleToCustomers()->pluck('public_id')->all(); + $internal = Comment::query()->without(['author', 'replies'])->internal()->pluck('public_id')->all(); + + expect($visible)->toEqualCanonicalizing(['comment_root', 'comment_foreign', 'comment_customer', 'comment_other_meta', 'comment_json_null']) + ->and($internal)->toBe(['comment_internal']); +}); + +test('public comment api hides internal comments and internal replies and accepts a visibility', function () { + $capsule = comment_visibility_database(); + $now = '2026-07-18 00:10:00'; + $capsule->getConnection('mysql')->table('comments')->insert([ + ['id' => 10, 'uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'public_id' => 'comment_internal', 'company_uuid' => 'company-1', 'subject_uuid' => 'user-subject', 'subject_type' => '\\' . User::class, 'author_uuid' => 'user-author', 'parent_comment_uuid' => null, 'content' => 'Internal', 'meta' => '{"visibility":"internal"}', 'created_at' => $now, 'updated_at' => $now], + ['id' => 11, 'uuid' => 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'public_id' => 'comment_internal_reply', 'company_uuid' => 'company-1', 'subject_uuid' => 'user-subject', 'subject_type' => '\\' . User::class, 'author_uuid' => 'user-author', 'parent_comment_uuid' => '11111111-1111-4111-8111-111111111111', 'content' => 'Internal reply', 'meta' => '{"visibility":"internal"}', 'created_at' => $now, 'updated_at' => $now], + ['id' => 12, 'uuid' => 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', 'public_id' => 'comment_customer_reply', 'company_uuid' => 'company-1', 'subject_uuid' => 'user-subject', 'subject_type' => '\\' . User::class, 'author_uuid' => 'user-author', 'parent_comment_uuid' => '11111111-1111-4111-8111-111111111111', 'content' => 'Customer reply', 'meta' => '{"visibility":"customer"}', 'created_at' => $now, 'updated_at' => $now], + ]); + + $listed = comment_controller()->query(comment_controller_query_request(['limit' => -1, 'without_parent' => '1'])); + $hidden = comment_controller()->find('comment_internal'); + + // resources resolve replies against the current request: a public route hides internal replies + comment_visibility_use_route('v1/comments'); + $root = collect($listed->collection)->firstWhere('public_id', 'comment_root'); + $publicReplies = collect((new Fleetbase\Http\Resources\Comment($root->resource))->resolve(request())['replies'])->map(fn ($reply) => $reply->resource->public_id)->all(); + + comment_visibility_use_route('int/v1/comments'); + $internalReplies = collect((new Fleetbase\Http\Resources\Comment($root->resource))->resolve(request())['replies'])->map(fn ($reply) => $reply->resource->public_id)->all(); + + $created = comment_controller()->create(comment_controller_create_request([ + 'subject' => ['id' => 'user_subject', 'type' => 'user'], + 'content' => 'Only for the team', + 'visibility' => 'internal', + ])); + + expect($listed->collection->pluck('public_id')->all())->not->toContain('comment_internal') + ->and($listed->collection->pluck('public_id')->all())->toContain('comment_root') + ->and($hidden->getStatusCode())->toBe(404) + ->and($publicReplies)->toBe(['comment_customer_reply']) + ->and($internalReplies)->toEqualCanonicalizing(['comment_customer_reply', 'comment_internal_reply']) + ->and($created->resource->getVisibility())->toBe('internal') + ->and((new CreateCommentRequest())->rules()['visibility'])->toContain('nullable'); +}); + +test('comment created broadcasts internal notes to the company only and customer comments to the subject too', function () { + comment_visibility_database(); + + comment_visibility_use_route('int/v1/comments'); + $internal = comment_visibility_publish(); + + comment_visibility_use_route('v1/comments'); + $customer = comment_visibility_publish(); + $unknown = Comment::publish(['subject_uuid' => 'missing-order', 'subject_type' => Fleetbase\Tests\Fixtures\Models\Order::class, 'content' => 'Lost', 'visibility' => 'customer']); + + $channels = fn (Comment $comment) => array_map(fn ($channel) => (string) $channel, (new Fleetbase\Events\CommentCreated($comment))->broadcastOn()); + + $event = new Fleetbase\Events\CommentCreated($customer); + $payload = $event->broadcastWith(); + + expect($event->broadcastAs())->toBe('comment.created') + ->and($channels($internal))->toBe(['company.company-1']) + ->and($channels($customer))->toBe(['company.company-1', 'order.order_1234567', 'order.order-uuid-1']) + ->and($channels($unknown))->toBe(['company.company-1']) + ->and($payload['event'])->toBe('comment.created') + ->and($payload['subject_id'])->toBe('order_1234567') + ->and($payload['data']['content'])->toBe('Leave at the side door.') + ->and($payload['data']['visibility'])->toBe('customer'); +}); + +test('creating a comment dispatches comment created and never fails when broadcasting does', function () { + comment_visibility_database(); + + $dispatched = new ArrayObject(); + Container::getInstance()->instance('events', new class($dispatched) { + public function __construct(private ArrayObject $dispatched) + { + } + + public function dispatch($event, $payload = [], $halt = false) + { + $this->dispatched->append($event); + + return null; + } + }); + Facade::clearResolvedInstance('events'); + + comment_visibility_use_route('v1/comments'); + $comment = comment_visibility_publish(); + + Container::getInstance()->instance('events', new class { + public function dispatch($event, $payload = [], $halt = false) + { + throw new RuntimeException('socket server unavailable'); + } + }); + Facade::clearResolvedInstance('events'); + $stillCreated = comment_visibility_publish(['content' => 'Second note']); + + expect($dispatched)->toHaveCount(1) + ->and($dispatched[0])->toBeInstanceOf(Fleetbase\Events\CommentCreated::class) + ->and($dispatched[0]->comment->is($comment))->toBeTrue() + ->and($stillCreated->exists)->toBeTrue(); +}); From 7bec840746150ce97badc6cf3c6500bcb4ec9c88 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 17:08:31 +0800 Subject: [PATCH 45/51] feat(chat): participant roles and system message meta Adds a nullable json meta column to chat_participants and chat_messages (guarded migration). - ChatParticipant: meta (fillable, json cast), ROLE_* constants (customer, driver, store, system) and getRole() reading meta.role; the resource adds 'role' and 'meta', so a message's sender.role reaches the apps over HTTP and the chat_message broadcasts - ChatMessage: meta (fillable, json cast), isSystem() (meta.system) and getSystemKind() (meta.kind); the resource adds 'system' and 'meta' and renders a missing sender as null instead of failing (the duplicate public 'sender' key it dropped was always overwritten by the participant resource) The public send-message endpoint still builds its own attributes, so customers cannot post system messages or set roles; existing chat behaviour is unchanged. --- ..._participants_and_chat_messages_tables.php | 42 +++++++++++ src/Http/Resources/ChatMessage.php | 6 +- src/Http/Resources/ChatParticipant.php | 3 + src/Models/ChatMessage.php | 30 +++++++- src/Models/ChatParticipant.php | 26 ++++++- tests/Unit/EventsAndExceptionsTest.php | 71 +++++++++++++++++++ tests/Unit/Models/ChatModelsTest.php | 47 ++++++++++++ 7 files changed, 220 insertions(+), 5 deletions(-) create mode 100644 migrations/2026_10_13_000000_add_meta_to_chat_participants_and_chat_messages_tables.php diff --git a/migrations/2026_10_13_000000_add_meta_to_chat_participants_and_chat_messages_tables.php b/migrations/2026_10_13_000000_add_meta_to_chat_participants_and_chat_messages_tables.php new file mode 100644 index 00000000..569b83f7 --- /dev/null +++ b/migrations/2026_10_13_000000_add_meta_to_chat_participants_and_chat_messages_tables.php @@ -0,0 +1,42 @@ +json('meta')->nullable()->after('chat_channel_uuid'); + }); + } + } + + public function down(): void + { + foreach (['chat_participants', 'chat_messages'] as $tableName) { + if (!Schema::hasColumn($tableName, 'meta')) { + continue; + } + + Schema::table($tableName, function (Blueprint $table) { + $table->dropColumn('meta'); + }); + } + } +}; diff --git a/src/Http/Resources/ChatMessage.php b/src/Http/Resources/ChatMessage.php index da8cee77..e433c3e9 100644 --- a/src/Http/Resources/ChatMessage.php +++ b/src/Http/Resources/ChatMessage.php @@ -3,6 +3,7 @@ namespace Fleetbase\Http\Resources; use Fleetbase\Support\Http; +use Fleetbase\Support\Utils; class ChatMessage extends FleetbaseResource { @@ -21,9 +22,10 @@ public function toArray($request) 'chat_channel_uuid' => $this->when(Http::isInternalRequest(), $this->chat_channel_uuid), 'chat_channel' => $this->when(Http::isPublicRequest(), $this->chatChannel->public_id), 'sender_uuid' => $this->when(Http::isInternalRequest(), $this->sender_uuid), - 'sender' => $this->when(Http::isPublicRequest(), $this->sender->public_id), 'content' => $this->content, - 'sender' => new ChatParticipant($this->sender), + 'sender' => $this->sender ? new ChatParticipant($this->sender) : null, + 'system' => method_exists($this->resource, 'isSystem') && $this->resource->isSystem(), + 'meta' => data_get($this, 'meta') ?: Utils::createObject(), 'attachments' => ChatAttachment::collection($this->attachments), 'receipts' => ChatReceipt::collection($this->receipts), 'updated_at' => $this->updated_at, diff --git a/src/Http/Resources/ChatParticipant.php b/src/Http/Resources/ChatParticipant.php index 5ad1a912..6ae8ced0 100644 --- a/src/Http/Resources/ChatParticipant.php +++ b/src/Http/Resources/ChatParticipant.php @@ -3,6 +3,7 @@ namespace Fleetbase\Http\Resources; use Fleetbase\Support\Http; +use Fleetbase\Support\Utils; class ChatParticipant extends FleetbaseResource { @@ -27,6 +28,8 @@ public function toArray($request) 'email' => $this->whenLoaded('user', fn () => $this->user->email), 'phone' => $this->whenLoaded('user', fn () => $this->user->phone), 'avatar_url' => $this->whenLoaded('user', fn () => $this->user->avatar_url), + 'role' => method_exists($this->resource, 'getRole') ? $this->resource->getRole() : null, + 'meta' => data_get($this, 'meta') ?: Utils::createObject(), 'is_online' => $this->is_online, 'last_seen_at' => $this->last_seen_at, 'updated_at' => $this->updated_at, diff --git a/src/Models/ChatMessage.php b/src/Models/ChatMessage.php index e2eafc1b..ffca18f7 100644 --- a/src/Models/ChatMessage.php +++ b/src/Models/ChatMessage.php @@ -2,6 +2,7 @@ namespace Fleetbase\Models; +use Fleetbase\Casts\Json; use Fleetbase\Notifications\ChatMessageReceived; use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasPublicId; @@ -34,7 +35,7 @@ class ChatMessage extends Model * * @var array */ - protected $fillable = ['company_uuid', 'chat_channel_uuid', 'sender_uuid', 'content']; + protected $fillable = ['company_uuid', 'chat_channel_uuid', 'sender_uuid', 'content', 'meta']; /** * The relationships to always load along with the model. @@ -43,6 +44,15 @@ class ChatMessage extends Model */ protected $with = ['sender', 'attachments', 'receipts']; + /** + * The attributes that should be cast to native types. + * + * @var array + */ + protected $casts = [ + 'meta' => Json::class, + ]; + /** * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ @@ -75,6 +85,24 @@ public function receipts() return $this->hasMany(ChatReceipt::class, 'chat_message_uuid', 'uuid')->whereHas('participant'); } + /** + * Whether this is a system message (`meta.system = true`), e.g. "order updated". + */ + public function isSystem(): bool + { + return (bool) data_get($this->meta, 'system', false); + } + + /** + * The system message kind (`meta.kind`), e.g. `order_updated` or `address_change_requested`. + */ + public function getSystemKind(): ?string + { + $kind = $this->isSystem() ? data_get($this->meta, 'kind') : null; + + return is_string($kind) ? $kind : null; + } + /** * Notify all participants in the chat channel (excluding the sender) of a new chat message. * diff --git a/src/Models/ChatParticipant.php b/src/Models/ChatParticipant.php index 985168c3..cb9c8638 100644 --- a/src/Models/ChatParticipant.php +++ b/src/Models/ChatParticipant.php @@ -2,6 +2,7 @@ namespace Fleetbase\Models; +use Fleetbase\Casts\Json; use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasPublicId; use Fleetbase\Traits\HasUuid; @@ -17,6 +18,15 @@ class ChatParticipant extends Model use HasApiModelBehavior; use SendsWebhooks; + /** + * Participant roles (`meta.role`), used by order chats to tell apps who is speaking. + */ + public const ROLE_CUSTOMER = 'customer'; + public const ROLE_DRIVER = 'driver'; + public const ROLE_STORE = 'store'; + public const ROLE_SYSTEM = 'system'; + public const ROLES = [self::ROLE_CUSTOMER, self::ROLE_DRIVER, self::ROLE_STORE, self::ROLE_SYSTEM]; + /** * The database table used by the model. * @@ -36,7 +46,7 @@ class ChatParticipant extends Model * * @var array */ - protected $fillable = ['company_uuid', 'chat_channel_uuid', 'user_uuid']; + protected $fillable = ['company_uuid', 'chat_channel_uuid', 'user_uuid', 'meta']; /** * Dynamic attributes that are appended to object. @@ -57,7 +67,9 @@ class ChatParticipant extends Model * * @var array */ - protected $casts = []; + protected $casts = [ + 'meta' => Json::class, + ]; public function user(): BelongsTo|Builder { @@ -107,6 +119,16 @@ public function getIsOnlineAttribute(): bool return $this->user ? $this->user->isOnline() : false; } + /** + * The participant's role (`meta.role`) when it is one of the known roles. + */ + public function getRole(): ?string + { + $role = data_get($this->meta, 'role'); + + return in_array($role, self::ROLES, true) ? $role : null; + } + /** * Get the chat participant's user name. */ diff --git a/tests/Unit/EventsAndExceptionsTest.php b/tests/Unit/EventsAndExceptionsTest.php index 4a8d39ab..1a1e5274 100644 --- a/tests/Unit/EventsAndExceptionsTest.php +++ b/tests/Unit/EventsAndExceptionsTest.php @@ -20,6 +20,7 @@ use Fleetbase\Exceptions\UnauthorizedRequestException; use Fleetbase\Listeners\SendResourceLifecycleWebhook; use Fleetbase\Models\ChatChannel; +use Fleetbase\Models\ChatMessage; use Fleetbase\Models\ChatParticipant; use Fleetbase\Models\Company; use Fleetbase\Models\Model as FleetbaseModel; @@ -429,6 +430,76 @@ public function getAction(string $key): ?string 'participant removed' => [ChatParticipantRemoved::class, 'chat.removed_participant'], ]); +test('chat participant and message resources expose the participant role and message meta', function () { + bind_test_container(); + + $channel = new ChatChannel(); + $channel->setRawAttributes(['uuid' => 'channel-uuid', 'public_id' => 'chat_channel_public'], true); + + $user = new EventsAndExceptionsUser(); + $user->setRawAttributes(['uuid' => 'user-uuid', 'public_id' => 'user_public', 'name' => 'Corner Cafe'], true); + + $participant = new EventsAndExceptionsChatParticipant(); + $participant->setRawAttributes([ + 'uuid' => 'participant-uuid', + 'public_id' => 'participant_public', + 'chat_channel_uuid' => 'channel-uuid', + 'user_uuid' => 'user-uuid', + 'meta' => '{"role":"store"}', + ], true); + $participant->setRelation('chatChannel', $channel); + $participant->setRelation('user', $user); + + $message = new ChatMessage(); + $message->setRawAttributes([ + 'uuid' => 'message-uuid', + 'public_id' => 'message_public', + 'chat_channel_uuid' => 'channel-uuid', + 'sender_uuid' => 'participant-uuid', + 'content' => 'Drop-off changed to 8 Marina View', + 'meta' => '{"system":true,"kind":"order_updated"}', + ], true); + $message->setRelation('chatChannel', $channel); + $message->setRelation('sender', $participant); + $message->setRelation('attachments', collect()); + $message->setRelation('receipts', collect()); + + // a system message may have no sender at all + $senderless = new ChatMessage(); + $senderless->setRawAttributes(['uuid' => 'message-2', 'public_id' => 'message_2', 'content' => 'Order updated', 'meta' => '{"system":true,"kind":"order_updated"}'], true); + $senderless->setRelation('chatChannel', $channel); + $senderless->setRelation('sender', null); + $senderless->setRelation('attachments', collect()); + $senderless->setRelation('receipts', collect()); + + $plain = new ChatMessage(); + $plain->setRawAttributes(['uuid' => 'message-3', 'public_id' => 'message_3', 'content' => 'Hi'], true); + $plain->setRelation('chatChannel', $channel); + $plain->setRelation('sender', $participant); + $plain->setRelation('attachments', collect()); + $plain->setRelation('receipts', collect()); + + $request = Illuminate\Http\Request::create('/v1/chat-channels/chat_channel_public'); + $request->setRouteResolver(fn () => new EventsAndExceptionsRoute('v1/chat-channels/{id}')); + app()->instance('request', $request); + + $resolve = fn ($resource) => json_decode(json_encode($resource->resolve($request)), true); + $publicMessage = $resolve(new Fleetbase\Http\Resources\ChatMessage($message)); + $publicNoSender = $resolve(new Fleetbase\Http\Resources\ChatMessage($senderless)); + $publicPlain = $resolve(new Fleetbase\Http\Resources\ChatMessage($plain)); + + expect($publicMessage['id'])->toBe('message_public') + ->and($publicMessage['system'])->toBeTrue() + ->and($publicMessage['meta'])->toBe(['system' => true, 'kind' => 'order_updated']) + ->and($publicMessage['sender']['id'])->toBe('participant_public') + ->and($publicMessage['sender']['role'])->toBe('store') + ->and($publicMessage['sender']['meta'])->toBe(['role' => 'store']) + ->and($publicNoSender['sender'])->toBeNull() + ->and($publicNoSender['system'])->toBeTrue() + ->and($publicPlain['system'])->toBeFalse() + ->and($publicPlain['meta'])->toBe([]); +}); + test('resource lifecycle events normalize payload children without dropping chat message relations', function () { bind_test_container(['api.version' => 'v1']); Carbon::setTestNow(Carbon::parse('2026-07-17 12:00:00')); diff --git a/tests/Unit/Models/ChatModelsTest.php b/tests/Unit/Models/ChatModelsTest.php index 2e39b922..2a0cb2d9 100644 --- a/tests/Unit/Models/ChatModelsTest.php +++ b/tests/Unit/Models/ChatModelsTest.php @@ -542,3 +542,50 @@ function chat_participant_with_name(string $uuid, ?string $name): ChatParticipan ->and($channel->logs()->getRelated())->toBeInstanceOf(ChatLog::class) ->and($channel->logs()->getForeignKeyName())->toBe('chat_channel_uuid'); }); + +it('adds a nullable meta column for participant roles and system messages', function () { + $capsule = chat_models_database(); + $connection = $capsule->getConnection('testing'); + $schema = $connection->getSchemaBuilder(); + app()->instance('db.schema', $schema); + Facade::clearResolvedInstance('db.schema'); + + $migration = require __DIR__ . '/../../../migrations/2026_10_13_000000_add_meta_to_chat_participants_and_chat_messages_tables.php'; + $migration->up(); + // guarded: running it twice is a no-op + $migration->up(); + + $now = '2026-07-18 12:00:00'; + $connection->table('chat_channels')->insert(['uuid' => 'channel-1', 'public_id' => 'chat_1', 'created_at' => $now, 'updated_at' => $now]); + $connection->table('chat_participants')->insert([ + ['uuid' => 'participant-store', 'chat_channel_uuid' => 'channel-1', 'user_uuid' => 'user-store', 'meta' => '{"role":"store"}', 'created_at' => $now, 'updated_at' => $now], + ['uuid' => 'participant-legacy', 'chat_channel_uuid' => 'channel-1', 'user_uuid' => 'user-legacy', 'meta' => null, 'created_at' => $now, 'updated_at' => $now], + ['uuid' => 'participant-odd', 'chat_channel_uuid' => 'channel-1', 'user_uuid' => 'user-odd', 'meta' => '{"role":"admin"}', 'created_at' => $now, 'updated_at' => $now], + ]); + $connection->table('chat_messages')->insert([ + ['uuid' => 'message-system', 'chat_channel_uuid' => 'channel-1', 'sender_uuid' => 'participant-store', 'content' => 'Drop-off changed', 'meta' => '{"system":true,"kind":"order_updated"}', 'created_at' => $now, 'updated_at' => $now], + ['uuid' => 'message-plain', 'chat_channel_uuid' => 'channel-1', 'sender_uuid' => 'participant-legacy', 'content' => 'Hello', 'meta' => null, 'created_at' => $now, 'updated_at' => $now], + ]); + + $store = ChatParticipant::query()->without('user')->whereKey('participant-store')->firstOrFail(); + $legacy = ChatParticipant::query()->without('user')->whereKey('participant-legacy')->firstOrFail(); + $odd = ChatParticipant::query()->without('user')->whereKey('participant-odd')->firstOrFail(); + $system = ChatMessage::query()->without(['sender', 'attachments', 'receipts'])->whereKey('message-system')->firstOrFail(); + $plain = ChatMessage::query()->without(['sender', 'attachments', 'receipts'])->whereKey('message-plain')->firstOrFail(); + + $migration->down(); + + expect($store->meta)->toBe(['role' => 'store']) + ->and($store->getRole())->toBe(ChatParticipant::ROLE_STORE) + ->and($legacy->getRole())->toBeNull() + ->and($odd->getRole())->toBeNull() + ->and(ChatParticipant::ROLES)->toBe(['customer', 'driver', 'store', 'system']) + ->and($system->isSystem())->toBeTrue() + ->and($system->getSystemKind())->toBe('order_updated') + ->and($plain->isSystem())->toBeFalse() + ->and($plain->getSystemKind())->toBeNull() + ->and((new ChatParticipant())->isFillable('meta'))->toBeTrue() + ->and((new ChatMessage())->isFillable('meta'))->toBeTrue() + ->and($schema->hasColumn('chat_participants', 'meta'))->toBeFalse() + ->and($schema->hasColumn('chat_messages', 'meta'))->toBeFalse(); +}); From 8694989dccc48274525b0b38dae53e860cce55bc Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 17:23:04 +0800 Subject: [PATCH 46/51] test(sms): fix guard fixtures and keep policy lookups off a bare container The blocked-prefix and satellite fixtures no longer matched the numbers they were meant to refuse, a bypassed reservation reported a trace, and the policy test's users fixture mixed column sets. Eloquent queries on a container with no connection resolver recurse until memory runs out, so the policy's company and admin lookups now skip when there is no database (only unit tests). --- src/Support/Sms/SmsPolicy.php | 20 ++++++++++++++++++- src/Support/Sms/SmsReservation.php | 4 ++++ tests/Unit/Support/Sms/PhoneValidatorTest.php | 2 +- tests/Unit/Support/Sms/SmsGuardTest.php | 4 ++-- tests/Unit/Support/Sms/SmsPolicyTest.php | 4 ++-- 5 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/Support/Sms/SmsPolicy.php b/src/Support/Sms/SmsPolicy.php index 0457b4b4..0fda966e 100644 --- a/src/Support/Sms/SmsPolicy.php +++ b/src/Support/Sms/SmsPolicy.php @@ -5,6 +5,7 @@ use Fleetbase\Models\Company; use Fleetbase\Models\Setting; use Fleetbase\Models\User; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; @@ -47,6 +48,10 @@ public static function defaults(): array */ public static function stored(): array { + if (!static::hasDatabase()) { + return []; + } + try { $value = Setting::where('key', 'system.' . static::SETTING_KEY)->value('value'); } catch (\Throwable $e) { @@ -56,6 +61,15 @@ public static function stored(): array return is_array($value) ? $value : []; } + /** + * Whether Eloquent has a connection to query at all. Only a bare container (unit tests) + * lacks one, and a query there does not fail cleanly, so the lookups are skipped. + */ + protected static function hasDatabase(): bool + { + return Model::getConnectionResolver() !== null; + } + /** * The effective policy: defaults with the stored override applied. */ @@ -305,7 +319,7 @@ public static function limitsFor(SmsContext $context, ?array $settings = null): */ public static function isOnProbation(?string $companyUuid, int $days): bool { - if (!$companyUuid || $days <= 0) { + if (!$companyUuid || $days <= 0 || !static::hasDatabase()) { return false; } @@ -348,6 +362,10 @@ public static function alertRecipients(?array $settings = null): array */ public static function adminUsers(): Collection { + if (!static::hasDatabase()) { + return new Collection(); + } + try { return User::withoutGlobalScopes()->where('type', 'admin')->whereNull('deleted_at')->get(); } catch (\Throwable $e) { diff --git a/src/Support/Sms/SmsReservation.php b/src/Support/Sms/SmsReservation.php index d4c049db..92a27745 100644 --- a/src/Support/Sms/SmsReservation.php +++ b/src/Support/Sms/SmsReservation.php @@ -48,6 +48,10 @@ public function isGuarded(): bool */ public function trace(): array { + if (!$this->isGuarded()) { + return []; + } + return array_filter([ 'company_uuid' => $this->context->companyUuid, 'tenant_type' => $this->context->tenantType, diff --git a/tests/Unit/Support/Sms/PhoneValidatorTest.php b/tests/Unit/Support/Sms/PhoneValidatorTest.php index 6936705c..fcdc85d3 100644 --- a/tests/Unit/Support/Sms/PhoneValidatorTest.php +++ b/tests/Unit/Support/Sms/PhoneValidatorTest.php @@ -41,7 +41,7 @@ }); test('phone validator applies blocked prefixes, blocked countries and a company allowlist', function () { - $prefix = PhoneValidator::validate('+447400100123', ['blocked_prefixes' => '+800, +4477']); + $prefix = PhoneValidator::validate('+447400100123', ['blocked_prefixes' => '+800, +4474']); $country = PhoneValidator::validate('+447400100123', ['blocked_countries' => 'gb,fr']); $allowed = PhoneValidator::validate('+447400100123', ['allowed_countries' => ['MN', 'SG']]); $fine = PhoneValidator::validate('+447400100123', ['blocked_prefixes' => ['+800'], 'blocked_countries' => ['FR'], 'allowed_countries' => ['GB']]); diff --git a/tests/Unit/Support/Sms/SmsGuardTest.php b/tests/Unit/Support/Sms/SmsGuardTest.php index f3df9854..31c6f0b7 100644 --- a/tests/Unit/Support/Sms/SmsGuardTest.php +++ b/tests/Unit/Support/Sms/SmsGuardTest.php @@ -43,7 +43,7 @@ function sms_guard_test_setup(array $guard = [], string $path = '/storefront/v1/ 'enabled' => true, 'mode' => 'enforce', 'alert_emails' => '', - 'validation' => ['enabled' => true, 'blocked_prefixes' => '+881'], + 'validation' => ['enabled' => true, 'blocked_prefixes' => '+881,+882'], 'limits' => [ 'destination' => ['cooldown_seconds' => 60, 'per_hour' => 3, 'per_day' => 5], 'ip' => ['per_15_minutes' => 50, 'per_day' => 100], @@ -144,7 +144,7 @@ function sms_guard_refusal(SmsGuard $guard, SmsContext $context): SmsBlockedExce ->and($premium->status())->toBe(422) ->and($premium->reason())->toBe('type_not_allowed') ->and($blocked->status())->toBe(422) - ->and($blocked->reason())->toBeIn(['prefix_blocked', 'invalid_destination']) + ->and($blocked->reason())->toBeIn(['prefix_blocked', 'type_not_allowed', 'invalid_destination']) ->and($guard->limiter()->attempts(SmsGuard::CACHE_PREFIX . 'company:company-1:per_day'))->toBe(0); }); diff --git a/tests/Unit/Support/Sms/SmsPolicyTest.php b/tests/Unit/Support/Sms/SmsPolicyTest.php index 3953576d..255dc72c 100644 --- a/tests/Unit/Support/Sms/SmsPolicyTest.php +++ b/tests/Unit/Support/Sms/SmsPolicyTest.php @@ -105,9 +105,9 @@ function sms_policy_database(array $guard = []): Capsule ['uuid' => 'company-new', 'public_id' => 'company_new', 'name' => 'New Co', 'created_at' => Carbon::now()->subDay(), 'updated_at' => Carbon::now()], ]); $db->table('users')->insert([ - ['uuid' => 'admin-1', 'type' => 'admin', 'name' => 'Root', 'email' => 'Root@example.test'], + ['uuid' => 'admin-1', 'type' => 'admin', 'name' => 'Root', 'email' => 'Root@example.test', 'deleted_at' => null], ['uuid' => 'admin-2', 'type' => 'admin', 'name' => 'Deleted', 'email' => 'gone@example.test', 'deleted_at' => Carbon::now()], - ['uuid' => 'user-1', 'type' => 'user', 'name' => 'Member', 'email' => 'member@example.test'], + ['uuid' => 'user-1', 'type' => 'user', 'name' => 'Member', 'email' => 'member@example.test', 'deleted_at' => null], ]); return $capsule; From 279047436754a3af78f3550be31b5acb3e4fb472 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 17:48:27 +0800 Subject: [PATCH 47/51] feat(sms): ledger, hashed single-use codes, learned breaker signals and the admin API Phase 1 of the SMS guard (phase 0 is the guard itself): - sms_sends ledger: every allowed send is reserved and settled with the provider outcome, every refusal is written with its reason, with tenant, purpose, source, address, destination country and the code row it carried. sms:ledger:prune masks numbers after 30 days and deletes rows after 90 (both configurable). - verification_codes gain company_uuid, identity, channel, ip_address, user_agent, sms_send_uuid and consumed_at (identity backfilled from meta for recent rows). generateSmsVerificationFor(['hashed' => true]) stores an HMAC and hands the message callback the plain code; findActiveFor()/findActiveForSubject() find the newest live code (new column or legacy meta), check() verifies, consume() uses it up. Everything degrades to meta-only on a table without the columns. - Two breaker signals read from the data: a burst to a country the company has never texted (armed after 50 ledgered sends), and SMS codes issued with almost none verified in the last hour. - SmsServiceChannel: a notification channel that sends through SmsService, so notification texts are guarded and ledgered too. - Admin API under int/v1/sms-guard: settings (mode, budgets, blocklists, alert recipients, per-company exceptions), reset, pauses, usage per organization and the ledger with filters. The console page follows separately. - The unrouted legacy Twilio-direct SMS login methods are removed. --- config/sms.php | 14 + ...26_10_13_000001_create_sms_sends_table.php | 56 ++++ ...ng_columns_to_verification_codes_table.php | 50 +++ src/Console/Commands/SmsGuardPause.php | 16 +- src/Console/Commands/SmsLedgerPrune.php | 36 +++ .../Internal/v1/AuthController.php | 163 ---------- .../Internal/v1/SmsGuardController.php | 300 ++++++++++++++++++ src/Models/SmsSend.php | 69 ++++ src/Models/VerificationCode.php | 163 +++++++++- .../Channels/SmsServiceChannel.php | 61 ++++ src/Notifications/SmsGuardAlert.php | 2 + src/Providers/CoreServiceProvider.php | 1 + src/Support/Sms/SmsBreaker.php | 114 ++++++- src/Support/Sms/SmsGuard.php | 29 +- src/Support/Sms/SmsLedger.php | 193 +++++++++++ src/Support/Sms/SmsPolicy.php | 8 + src/Support/Sms/SmsReservation.php | 5 + src/routes.php | 10 + tests/Fixtures/Support/SmsGuardFixture.php | 135 ++++++++ tests/Unit/Console/SmsLedgerPruneTest.php | 61 ++++ .../Http/AuthControllerVerificationTest.php | 179 ----------- tests/Unit/Http/SmsGuardControllerTest.php | 215 +++++++++++++ .../Models/VerificationCodeTracingTest.php | 218 +++++++++++++ .../Notifications/SmsServiceChannelTest.php | 81 +++++ tests/Unit/Support/Sms/SmsGuardLedgerTest.php | 260 +++++++++++++++ tests/Unit/Support/Sms/SmsLedgerTest.php | 122 +++++++ 26 files changed, 2193 insertions(+), 368 deletions(-) create mode 100644 migrations/2026_10_13_000001_create_sms_sends_table.php create mode 100644 migrations/2026_10_13_000002_add_tracing_columns_to_verification_codes_table.php create mode 100644 src/Console/Commands/SmsLedgerPrune.php create mode 100644 src/Http/Controllers/Internal/v1/SmsGuardController.php create mode 100644 src/Models/SmsSend.php create mode 100644 src/Notifications/Channels/SmsServiceChannel.php create mode 100644 src/Support/Sms/SmsLedger.php create mode 100644 tests/Fixtures/Support/SmsGuardFixture.php create mode 100644 tests/Unit/Console/SmsLedgerPruneTest.php create mode 100644 tests/Unit/Http/SmsGuardControllerTest.php create mode 100644 tests/Unit/Models/VerificationCodeTracingTest.php create mode 100644 tests/Unit/Notifications/SmsServiceChannelTest.php create mode 100644 tests/Unit/Support/Sms/SmsGuardLedgerTest.php create mode 100644 tests/Unit/Support/Sms/SmsLedgerTest.php diff --git a/config/sms.php b/config/sms.php index 1e430a7c..4f9316a8 100644 --- a/config/sms.php +++ b/config/sms.php @@ -199,6 +199,20 @@ 'alert_percentages' => [50, 80], 'distinct_destinations_per_10m' => 25, 'auto_pauses_before_manual' => 2, + // Sends in 10 minutes to a country the company has not texted in the last + // 30 days, once it has at least `new_country_min_history` ledgered sends. + 'new_country_min_sends' => 10, + 'new_country_min_history' => 50, + // Verification codes issued by SMS in the last hour versus codes actually + // verified: pumping has a conversion of about zero. + 'conversion_min_issued' => 20, + 'conversion_min_percent' => 10, + ], + + // The send ledger (sms_sends): every send and refusal with who, where and why. + 'ledger' => [ + 'retention_days' => env('SMS_GUARD_LEDGER_RETENTION_DAYS', 90), + 'mask_after_days' => env('SMS_GUARD_LEDGER_MASK_AFTER_DAYS', 30), ], // Per-purpose overrides, keyed by the verification code's `for` value. diff --git a/migrations/2026_10_13_000001_create_sms_sends_table.php b/migrations/2026_10_13_000001_create_sms_sends_table.php new file mode 100644 index 00000000..e0db18ee --- /dev/null +++ b/migrations/2026_10_13_000001_create_sms_sends_table.php @@ -0,0 +1,56 @@ +uuid('uuid')->primary(); + $table->char('company_uuid', 36)->nullable()->index(); + $table->string('tenant_type', 32)->nullable(); + $table->char('tenant_uuid', 36)->nullable()->index(); + $table->string('purpose', 64)->index(); + $table->string('source', 32); + $table->string('destination', 32)->nullable(); + $table->string('destination_hash', 64)->nullable()->index(); + $table->char('destination_country', 3)->nullable()->index(); + $table->string('destination_type', 32)->nullable(); + $table->string('status', 16)->index(); + $table->string('block_reason', 64)->nullable(); + $table->string('provider', 32)->nullable(); + $table->string('provider_message_id', 128)->nullable(); + $table->text('error')->nullable(); + $table->string('subject_type')->nullable(); + $table->char('subject_uuid', 36)->nullable(); + $table->char('verification_code_uuid', 36)->nullable()->index(); + $table->string('ip_address', 45)->nullable()->index(); + $table->string('user_agent', 512)->nullable(); + $table->json('meta')->nullable(); + $table->timestamps(); + + $table->index(['company_uuid', 'created_at']); + $table->index(['tenant_uuid', 'created_at']); + $table->index(['status', 'created_at']); + $table->index('created_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('sms_sends'); + } +}; diff --git a/migrations/2026_10_13_000002_add_tracing_columns_to_verification_codes_table.php b/migrations/2026_10_13_000002_add_tracing_columns_to_verification_codes_table.php new file mode 100644 index 00000000..5eb75bf8 --- /dev/null +++ b/migrations/2026_10_13_000002_add_tracing_columns_to_verification_codes_table.php @@ -0,0 +1,50 @@ +identity` for recent rows + * so lookups can move off the JSON path at once. + */ + public function up(): void + { + Schema::table('verification_codes', function (Blueprint $table) { + $table->char('company_uuid', 36)->nullable()->index()->after('subject_type'); + $table->string('identity', 191)->nullable()->after('company_uuid'); + $table->string('channel', 16)->nullable()->after('identity'); + $table->string('ip_address', 45)->nullable()->after('channel'); + $table->string('user_agent', 512)->nullable()->after('ip_address'); + $table->char('sms_send_uuid', 36)->nullable()->index()->after('user_agent'); + $table->dateTime('consumed_at')->nullable()->after('expires_at'); + + $table->index(['for', 'identity', 'created_at']); + }); + + if (DB::connection()->getDriverName() === 'mysql') { + DB::table('verification_codes') + ->whereNull('identity') + ->where('created_at', '>=', now()->subDays(30)) + ->update(['identity' => DB::raw("JSON_UNQUOTE(JSON_EXTRACT(meta, '$.identity'))")]); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('verification_codes', function (Blueprint $table) { + $table->dropIndex(['for', 'identity', 'created_at']); + $table->dropColumn(['company_uuid', 'identity', 'channel', 'ip_address', 'user_agent', 'sms_send_uuid', 'consumed_at']); + }); + } +}; diff --git a/src/Console/Commands/SmsGuardPause.php b/src/Console/Commands/SmsGuardPause.php index 0111b934..b7e77348 100644 --- a/src/Console/Commands/SmsGuardPause.php +++ b/src/Console/Commands/SmsGuardPause.php @@ -2,7 +2,6 @@ namespace Fleetbase\Console\Commands; -use Fleetbase\Models\Company; use Fleetbase\Support\Sms\SmsBreaker; use Fleetbase\Support\Sms\SmsGuard; use Illuminate\Console\Command; @@ -46,22 +45,11 @@ public function handle(): int */ public static function resolveScope(string $input): ?string { - $input = trim($input); - - if ($input === SmsBreaker::SCOPE_PLATFORM || str_starts_with($input, 'company:') || str_starts_with($input, 'tenant:')) { - return $input; - } - - $company = Company::withoutGlobalScopes() - ->where('uuid', $input) - ->orWhere('public_id', $input) - ->first(['uuid']); - - return $company ? 'company:' . $company->uuid : null; + return SmsBreaker::resolveScope($input); } public static function companyUuidFromScope(string $scope): ?string { - return str_starts_with($scope, 'company:') ? substr($scope, strlen('company:')) : null; + return SmsBreaker::companyUuidFromScope($scope); } } diff --git a/src/Console/Commands/SmsLedgerPrune.php b/src/Console/Commands/SmsLedgerPrune.php new file mode 100644 index 00000000..bb11744b --- /dev/null +++ b/src/Console/Commands/SmsLedgerPrune.php @@ -0,0 +1,36 @@ +option('days') !== null ? (int) $this->option('days') : (int) config('sms.guard.ledger.retention_days', 90); + $maskAfter = $this->option('mask-after') !== null ? (int) $this->option('mask-after') : (int) config('sms.guard.ledger.mask_after_days', 30); + + try { + $result = SmsLedger::prune($retention, $maskAfter); + } catch (\Throwable $e) { + $this->error('The SMS ledger could not be pruned: ' . $e->getMessage()); + + return self::FAILURE; + } + + $this->info(sprintf('SMS ledger: masked %d row(s) older than %d day(s), deleted %d row(s) older than %d day(s).', $result['masked'], $maskAfter, $result['deleted'], $retention)); + + return self::SUCCESS; + } +} diff --git a/src/Http/Controllers/Internal/v1/AuthController.php b/src/Http/Controllers/Internal/v1/AuthController.php index c7583fa5..844d588c 100644 --- a/src/Http/Controllers/Internal/v1/AuthController.php +++ b/src/Http/Controllers/Internal/v1/AuthController.php @@ -26,7 +26,6 @@ use Fleetbase\Support\Auth; use Fleetbase\Support\TwoFactorAuth; use Fleetbase\Support\Utils; -use Fleetbase\Twilio\Support\Laravel\Facade as Twilio; use Illuminate\Http\Request; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Cache; @@ -242,133 +241,6 @@ public function bootstrap(Request $request) ->header('Cache-Control', 'private, max-age=300'); } - /** - * Send a verification SMS code. - * - * @param \\Illuminate\Http\Request $request - * - * @return \Illuminate\Http\Response $response - */ - public function sendVerificationSms(Request $request) - { - // Users phone number - $phone = $queryPhone = $request->input('phone'); - $countryCode = $request->input('countryCode'); - $for = $request->input('driver'); - - // set phone number - if (!Str::startsWith($queryPhone, '+')) { - $queryPhone = '+' . $countryCode . $phone; - } - - // Make sure user exists with phone number - $userExistsQuery = User::where('phone', $queryPhone)->whereNull('deleted_at')->withoutGlobalScopes(); - - if ($for === 'driver') { - $userExistsQuery->where('type', 'driver'); - } - - $userExists = $userExistsQuery->exists(); - - if (!$userExists) { - return response()->error('No user with this phone # found.'); - } - - // Generate hto - $verifyCode = mt_rand(100000, 999999); - $verifyCodeKey = Str::slug($queryPhone . '_verify_code', '_'); - - // Send user their verification code - try { - Twilio::message($queryPhone, 'Your Fleetbase authentication code is ' . $verifyCode); - } catch (\Exception|\Twilio\Exceptions\RestException $e) { - return response()->json(['error' => $e->getMessage()], 400); - } - - // Store verify code for this number with a 10-minute TTL to prevent replay attacks - Redis::setex($verifyCodeKey, 600, $verifyCode); - - // 200 OK - return response()->json(['status' => 'OK']); - } - - /** - * Authenticate a user with SMS code. - * - * @param \\Illuminate\Http\Request $request - * - * @return \Illuminate\Http\Response $response - */ - public function authenticateSmsCode(Request $request) - { - // Users phone number - $phone = $queryPhone = $request->input('phone'); - $countryCode = $request->input('countryCode'); - - // set phone number - if (!Str::startsWith($queryPhone, '+')) { - $queryPhone = '+' . $countryCode . $phone; - } - - // Users verfiy code entered - $verifyCode = $request->input('code'); - $verifyCodeKey = Str::slug($queryPhone . '_verify_code', '_'); - - // Retrieve the stored verification code from Redis - $storedVerifyCode = Redis::get($verifyCodeKey); - - // Retrieve the optional testing bypass code from configuration. - // This is configurable via the SMS_AUTH_BYPASS_CODE environment variable - // and is intended for development/testing environments only. - // It MUST be left unset (null) in production deployments. - $bypassCode = config('fleetbase.sms_auth_bypass_code'); - - // Verify the submitted code against the stored OTP using a constant-time - // comparison to prevent timing attacks. If a bypass code is configured - // and the environment is not production, also allow that code. - $isValidOtp = !empty($storedVerifyCode) && hash_equals((string) $storedVerifyCode, (string) $verifyCode); - $isBypassValid = !empty($bypassCode) && !app()->environment('production') && hash_equals((string) $bypassCode, (string) $verifyCode); - - if (!$isValidOtp && !$isBypassValid) { - return response()->error('Invalid verification code'); - } - - // Remove from redis - Redis::del($verifyCodeKey); - - // get user for phone number - $user = User::where('phone', $queryPhone)->first(); - - // Attempt authentication - if ($user) { - // Set authenticatin user - Auth::login($user); - - // Generate token - try { - $token = $user->createToken($user->phone)->plainTextToken; - } catch (\Exception $e) { - return response()->error($e->getMessage()); - } - - // @codeCoverageIgnoreStart - // The driver relation is supplied by Fleet-Ops; core-api tests cannot load that package relation directly. - if ($user->type === 'driver') { - $user->load(['driver']); - } - // @codeCoverageIgnoreEnd - - // Send message to notify users authentication - return response()->json([ - 'token' => $token, - 'user' => $user, - ]); - } - - // If unable to authenticate user, respond with error - return response()->json('Authentication failed', 401); - } - /** * Create resend verification code session. * @@ -516,41 +388,6 @@ public function verifyEmail(Request $request) ]); } - /** - * Allow user to verify SMS code. - * - * @param \\Illuminate\Http\Request $request - * - * @return \Illuminate\Http\Response $response - */ - public function verifySmsCode(Request $request) - { - // Users phone number - $phone = $request->input('phone'); - - // Users verfiy code entered - $verifyCode = $request->input('code'); - $verifyCodeKey = Str::slug($phone . '_verify_code', '_'); - - // Generate hto - $storedVerifyCode = Redis::get($verifyCodeKey); - - // Verify - if ($verifyCode === $storedVerifyCode) { - // Remove from redis - Redis::del($verifyCodeKey); - - // 200 OK - return response()->json([ - 'status' => 'OK', - 'message' => 'Code verified', - ]); - } - - // 400 ERROR - return response()->error('Invalid verification code'); - } - /** * Creates a new company and user account. * diff --git a/src/Http/Controllers/Internal/v1/SmsGuardController.php b/src/Http/Controllers/Internal/v1/SmsGuardController.php new file mode 100644 index 00000000..2b1fcfd0 --- /dev/null +++ b/src/Http/Controllers/Internal/v1/SmsGuardController.php @@ -0,0 +1,300 @@ +json($this->settingsPayload(SmsPolicy::settings())); + } + + /** + * Save the administrator's policy; it applies within a minute on every worker. + */ + public function saveSettings(AdminRequest $request): JsonResponse + { + $validated = $request->validate([ + 'enabled' => ['required', 'boolean'], + 'mode' => ['required', Rule::in([SmsPolicy::MODE_ENFORCE, SmsPolicy::MODE_MONITOR])], + 'alert_emails' => ['sometimes', 'array'], + 'alert_emails.*' => ['email'], + 'validation' => ['sometimes', 'array'], + 'validation.enabled' => ['sometimes', 'boolean'], + 'validation.allowed_types' => ['sometimes', 'array'], + 'validation.allowed_types.*' => ['string', 'max:32'], + 'validation.blocked_countries' => ['sometimes', 'array'], + 'validation.blocked_countries.*' => ['string', 'size:2'], + 'validation.blocked_prefixes' => ['sometimes', 'array'], + 'validation.blocked_prefixes.*' => ['string', 'max:8'], + 'limits' => ['sometimes', 'array'], + 'limits.destination.cooldown_seconds' => ['nullable', 'integer', 'min:0', 'max:86400'], + 'limits.destination.per_hour' => ['nullable', 'integer', 'min:0', 'max:100000'], + 'limits.destination.per_day' => ['nullable', 'integer', 'min:0', 'max:100000'], + 'limits.ip.per_15_minutes' => ['nullable', 'integer', 'min:0', 'max:100000'], + 'limits.ip.per_day' => ['nullable', 'integer', 'min:0', 'max:100000'], + 'limits.company.per_minute' => ['nullable', 'integer', 'min:0', 'max:1000000'], + 'limits.company.per_hour' => ['nullable', 'integer', 'min:0', 'max:1000000'], + 'limits.company.per_day' => ['nullable', 'integer', 'min:0', 'max:1000000'], + 'limits.tenant.per_minute' => ['nullable', 'integer', 'min:0', 'max:1000000'], + 'limits.tenant.per_hour' => ['nullable', 'integer', 'min:0', 'max:1000000'], + 'limits.tenant.per_day' => ['nullable', 'integer', 'min:0', 'max:1000000'], + 'limits.probation.days' => ['nullable', 'integer', 'min:0', 'max:365'], + 'limits.probation.per_day' => ['nullable', 'integer', 'min:0', 'max:1000000'], + 'limits.platform.public.per_minute' => ['nullable', 'integer', 'min:0', 'max:10000000'], + 'limits.platform.public.per_hour' => ['nullable', 'integer', 'min:0', 'max:10000000'], + 'limits.platform.public.per_day' => ['nullable', 'integer', 'min:0', 'max:10000000'], + 'limits.platform.trusted.per_minute' => ['nullable', 'integer', 'min:0', 'max:10000000'], + 'limits.platform.trusted.per_hour' => ['nullable', 'integer', 'min:0', 'max:10000000'], + 'limits.platform.trusted.per_day' => ['nullable', 'integer', 'min:0', 'max:10000000'], + 'breaker' => ['sometimes', 'array'], + 'breaker.pause_minutes' => ['nullable', 'integer', 'min:1', 'max:10080'], + 'breaker.alert_percentages' => ['sometimes', 'array'], + 'breaker.alert_percentages.*' => ['integer', 'min:1', 'max:99'], + 'breaker.distinct_destinations_per_10m' => ['nullable', 'integer', 'min:0', 'max:100000'], + 'breaker.auto_pauses_before_manual' => ['nullable', 'integer', 'min:1', 'max:100'], + 'breaker.new_country_min_sends' => ['nullable', 'integer', 'min:0', 'max:100000'], + 'breaker.new_country_min_history' => ['nullable', 'integer', 'min:0', 'max:1000000'], + 'breaker.conversion_min_issued' => ['nullable', 'integer', 'min:0', 'max:100000'], + 'breaker.conversion_min_percent' => ['nullable', 'integer', 'min:0', 'max:100'], + 'overrides' => ['sometimes', 'array'], + 'overrides.*.company_uuid' => ['required', 'string', 'distinct', Rule::exists('companies', 'uuid')], + 'overrides.*.unlimited' => ['sometimes', 'boolean'], + 'overrides.*.per_minute' => ['nullable', 'integer', 'min:0', 'max:1000000'], + 'overrides.*.per_hour' => ['nullable', 'integer', 'min:0', 'max:1000000'], + 'overrides.*.per_day' => ['nullable', 'integer', 'min:0', 'max:1000000'], + 'overrides.*.allowed_countries' => ['sometimes', 'array'], + 'overrides.*.allowed_countries.*' => ['string', 'size:2'], + 'overrides.*.note' => ['nullable', 'string', 'max:255'], + ]); + + // Lists replace, maps merge: an override removed from the list is gone, while the + // pauses and anything not on the page survive the save. + $validated['overrides'] = array_values($validated['overrides'] ?? []); + $validated['alert_emails'] = array_values($validated['alert_emails'] ?? []); + + return response()->json($this->settingsPayload(SmsPolicy::update($validated))); + } + + /** + * Discard the administrator's policy and pauses, back to the environment defaults. + */ + public function resetSettings(AdminRequest $request): JsonResponse + { + return response()->json($this->settingsPayload(SmsPolicy::reset())); + } + + /** + * The pauses in force. + */ + public function pauses(AdminRequest $request): JsonResponse + { + return response()->json(['pauses' => $this->pausesPayload()]); + } + + /** + * Pause a company, a store or network, or the platform. + */ + public function pause(AdminRequest $request): JsonResponse + { + $validated = $request->validate([ + 'scope' => ['required', 'string', 'max:120'], + 'minutes' => ['nullable', 'integer', 'min:1', 'max:525600'], + 'reason' => ['nullable', 'string', 'max:255'], + ]); + + $scope = SmsBreaker::resolveScope($validated['scope']); + if ($scope === null) { + return response()->error('No organization matches that scope.', 422); + } + + SmsGuard::make()->breaker()->pause( + $scope, + isset($validated['minutes']) ? (int) $validated['minutes'] : null, + (string) ($validated['reason'] ?? 'manual'), + false, + SmsBreaker::companyUuidFromScope($scope) + ); + + return response()->json(['pauses' => $this->pausesPayload()]); + } + + /** + * Lift a pause. + */ + public function unpause(AdminRequest $request, string $scope): JsonResponse + { + $resolved = SmsBreaker::resolveScope(urldecode($scope)); + if ($resolved === null) { + return response()->error('No organization matches that scope.', 422); + } + + SmsGuard::make()->breaker()->unpause($resolved); + + return response()->json(['pauses' => $this->pausesPayload()]); + } + + /** + * Sends per organization over a recent window, against their budgets. + */ + public function usage(AdminRequest $request): JsonResponse + { + $request->validate([ + 'hours' => ['sometimes', 'integer', 'min:1', 'max:720'], + 'limit' => ['sometimes', 'integer', 'min:1', 'max:200'], + ]); + + $hours = (int) $request->input('hours', 24); + $limit = (int) $request->input('limit', 50); + $settings = SmsPolicy::settings(); + $since = Carbon::now()->subHours($hours); + + $rows = SmsSend::query() + ->selectRaw('company_uuid, ' + . 'sum(case when status = ? then 1 else 0 end) as sent, ' + . 'sum(case when status = ? then 1 else 0 end) as failed, ' + . 'sum(case when status = ? then 1 else 0 end) as blocked, ' + . 'count(distinct destination_hash) as destinations, ' + . 'max(created_at) as last_send_at', [SmsSend::STATUS_SENT, SmsSend::STATUS_FAILED, SmsSend::STATUS_BLOCKED]) + ->where('created_at', '>=', $since) + ->whereNotNull('company_uuid') + ->groupBy('company_uuid') + ->orderByDesc('sent') + ->limit($limit) + ->get(); + + $companies = Company::whereIn('uuid', $rows->pluck('company_uuid')->all())->get(['uuid', 'public_id', 'name'])->keyBy('uuid'); + + $usage = $rows->map(function ($row) use ($companies, $settings) { + $company = $companies->get($row->company_uuid); + $override = SmsPolicy::overrideFor($row->company_uuid, $settings); + $budget = $override && $override['unlimited'] ? null : ($override['per_day'] ?? $settings['limits']['company']['per_day']); + + return [ + 'company_uuid' => $row->company_uuid, + 'company_id' => $company?->public_id, + 'company_name' => $company?->name, + 'sent' => (int) $row->sent, + 'failed' => (int) $row->failed, + 'blocked' => (int) $row->blocked, + 'destinations' => (int) $row->destinations, + 'last_send_at' => $row->last_send_at, + 'daily_budget' => $budget, + 'paused' => SmsGuard::make()->breaker()->pausedUntil('company:' . $row->company_uuid) !== null, + ]; + })->values(); + + return response()->json([ + 'hours' => $hours, + 'ledger_available' => SmsLedger::available(), + 'usage' => $usage, + ]); + } + + /** + * Recent ledger rows, newest first, filtered by organization, status, purpose or number. + */ + public function ledger(AdminRequest $request): JsonResponse + { + $request->validate([ + 'company_uuid' => ['sometimes', 'string', 'max:36'], + 'status' => ['sometimes', Rule::in([SmsSend::STATUS_RESERVED, SmsSend::STATUS_SENT, SmsSend::STATUS_FAILED, SmsSend::STATUS_BLOCKED])], + 'purpose' => ['sometimes', 'string', 'max:64'], + 'destination' => ['sometimes', 'string', 'max:32'], + 'hours' => ['sometimes', 'integer', 'min:1', 'max:2160'], + 'limit' => ['sometimes', 'integer', 'min:1', 'max:500'], + ]); + + $query = SmsSend::query() + ->where('created_at', '>=', Carbon::now()->subHours((int) $request->input('hours', 24))) + ->orderByDesc('created_at') + ->limit((int) $request->input('limit', 100)); + + if ($request->filled('company_uuid')) { + $query->where('company_uuid', $request->input('company_uuid')); + } + if ($request->filled('status')) { + $query->where('status', $request->input('status')); + } + if ($request->filled('purpose')) { + $query->where('purpose', $request->input('purpose')); + } + if ($request->filled('destination')) { + $query->where('destination_hash', SmsLedger::hash((string) $request->input('destination'))); + } + + return response()->json([ + 'ledger_available' => SmsLedger::available(), + 'sends' => $query->get(), + ]); + } + + protected function settingsPayload(array $settings): array + { + $companies = Company::whereIn('uuid', array_column($settings['overrides'], 'company_uuid')) + ->get(['uuid', 'public_id', 'name']) + ->keyBy('uuid'); + + $settings['overrides'] = array_map(function (array $override) use ($companies) { + $company = $companies->get($override['company_uuid']); + + return array_merge($override, [ + 'company_id' => $company?->public_id, + 'company_name' => $company?->name, + ]); + }, $settings['overrides']); + + return [ + 'settings' => $settings, + 'defaults' => SmsPolicy::defaults(), + 'pauses' => $this->pausesPayload(), + 'ledger_available' => SmsLedger::available(), + ]; + } + + /** + * @return array> + */ + protected function pausesPayload(): array + { + $pauses = collect(SmsGuard::make()->breaker()->pauses())->map(function (array $pause, string $scope) { + $pause['company_uuid'] = $pause['company_uuid'] ?: SmsBreaker::companyUuidFromScope($scope); + + return $pause; + }); + $uuids = $pauses->pluck('company_uuid')->filter()->unique()->values()->all(); + $names = $uuids ? Company::whereIn('uuid', $uuids)->get(['uuid', 'public_id', 'name'])->keyBy('uuid') : collect(); + + return $pauses->map(function (array $pause, string $scope) use ($names) { + $company = $pause['company_uuid'] ? $names->get($pause['company_uuid']) : null; + + return array_merge($pause, [ + 'scope' => $scope, + 'platform' => $scope === SmsBreaker::SCOPE_PLATFORM, + 'company_id' => $company?->public_id, + 'company_name' => $company?->name, + 'until_iso' => $pause['until'] ? Carbon::createFromTimestamp($pause['until'])->toIso8601String() : null, + ]); + })->values()->all(); + } +} diff --git a/src/Models/SmsSend.php b/src/Models/SmsSend.php new file mode 100644 index 00000000..8b2e2a06 --- /dev/null +++ b/src/Models/SmsSend.php @@ -0,0 +1,69 @@ + Json::class, + ]; + + public function getConnectionName() + { + return $this->connection ?: config('fleetbase.connection.db', 'mysql'); + } + + protected static function booted(): void + { + static::creating(function (self $send) { + if (blank($send->uuid)) { + $send->uuid = (string) Str::uuid(); + } + }); + } +} diff --git a/src/Models/VerificationCode.php b/src/Models/VerificationCode.php index 15049dee..d31550c7 100644 --- a/src/Models/VerificationCode.php +++ b/src/Models/VerificationCode.php @@ -7,6 +7,8 @@ use Fleetbase\Services\SmsService; use Fleetbase\Support\Sms\SmsContext; use Fleetbase\Support\Sms\SmsGuard; +use Fleetbase\Support\Sms\SmsLedger; +use Fleetbase\Support\Sms\SmsReservation; use Fleetbase\Support\Utils; use Fleetbase\Traits\Expirable; use Fleetbase\Traits\HasMetaAttributes; @@ -15,6 +17,7 @@ use Fleetbase\Twilio\Support\Laravel\Facade as Twilio; use Illuminate\Support\Arr; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Mail; class VerificationCode extends Model @@ -36,7 +39,7 @@ class VerificationCode extends Model * * @var array */ - protected $fillable = ['subject_uuid', 'subject_type', 'code', 'for', 'expires_at', 'meta', 'status']; + protected $fillable = ['subject_uuid', 'subject_type', 'code', 'for', 'expires_at', 'meta', 'status', 'company_uuid', 'identity', 'channel', 'ip_address', 'user_agent', 'sms_send_uuid', 'consumed_at']; /** * The attributes that should be cast to native types. @@ -44,10 +47,14 @@ class VerificationCode extends Model * @var array */ protected $casts = [ - 'expires_at' => 'datetime', - 'meta' => Json::class, + 'expires_at' => 'datetime', + 'consumed_at' => 'datetime', + 'meta' => Json::class, ]; + public const STATUS_USED = 'used'; + public const STATUS_LOCKED = 'locked'; + /** * Dynamic attributes that are appended to object. * @@ -167,6 +174,113 @@ public function attemptsLeft(int $maxAttempts = 3): int return max(0, $maxAttempts - (int) $this->getMeta('attempts', 0)); } + /** + * Mark a code used so it cannot verify again. The row stays, for the ledger. + */ + public function consume(): void + { + $this->status = self::STATUS_USED; + if (static::supportsTracingColumns()) { + $this->consumed_at = Carbon::now(); + } + $this->setMeta('consumed_at', Carbon::now()->toIso8601String()); + $this->save(); + } + + /** + * The newest live code filed under an identity for a purpose: not expired (the default + * scope), not used, not locked. Looks at the `identity` column where the table has it + * and at `meta->identity` otherwise, so rows written before the column exists resolve. + */ + public static function findActiveFor(string $for, string $identity, ?string $subjectUuid = null): ?static + { + $query = static::query()->where('for', $for); + + if (static::supportsTracingColumns()) { + $query->where(function ($query) use ($identity) { + $query->where('identity', $identity)->orWhere('meta->identity', $identity); + }); + } else { + $query->where('meta->identity', $identity); + } + + if ($subjectUuid) { + $query->where('subject_uuid', $subjectUuid); + } + + return $query + ->where(function ($query) { + $query->whereNull('status')->orWhereNotIn('status', [self::STATUS_USED, self::STATUS_LOCKED]); + }) + ->orderByDesc('created_at') + ->first(); + } + + /** + * The newest live code for a subject and purpose. + */ + public static function findActiveForSubject(string $for, string $subjectUuid): ?static + { + return static::query() + ->where('for', $for) + ->where('subject_uuid', $subjectUuid) + ->where(function ($query) { + $query->whereNull('status')->orWhereNotIn('status', [self::STATUS_USED, self::STATUS_LOCKED]); + }) + ->orderByDesc('created_at') + ->first(); + } + + /** + * Whether the table has the tracing columns added in October 2026 (company, identity, + * channel, address, ledger link, consumed_at). An install that has not migrated yet, + * and a test schema, keep working with `meta` alone. Remembered in the cache for five + * minutes, so a migration is picked up without a restart. + */ + public static function supportsTracingColumns(): bool + { + $model = new static(); + $check = function () use ($model): bool { + try { + return (bool) $model->getConnection()->getSchemaBuilder()->hasColumn($model->getTable(), 'identity'); + } catch (\Throwable $e) { + return false; + } + }; + + try { + return (bool) Cache::remember('verification_codes:tracing_columns:' . ($model->getConnectionName() ?: 'default'), 300, $check); + } catch (\Throwable $e) { + return $check(); + } + } + + /** + * What a code row keeps about who asked for it, set only where the table has the columns. + */ + protected static function tracingAttributes(?string $companyUuid, string $channel, ?string $identity, ?SmsReservation $reservation = null): array + { + if (!static::supportsTracingColumns()) { + return []; + } + + $request = null; + try { + $request = request(); + } catch (\Throwable $e) { + // no request: a console or queued send + } + + return [ + 'company_uuid' => $companyUuid ?: null, + 'identity' => $identity ?: null, + 'channel' => $channel, + 'ip_address' => $reservation?->context->ip ?? ($request ? $request->ip() : null), + 'user_agent' => $reservation?->context->userAgent ?? ($request && $request->userAgent() ? substr((string) $request->userAgent(), 0, 250) : null), + 'sms_send_uuid' => $reservation?->sendUuid, + ]; + } + /** * @return \Illuminate\Database\Eloquent\Relations\MorphTo */ @@ -218,10 +332,16 @@ public static function generateFor($subject = null, $for = 'general_verification public static function generateEmailVerificationFor($subject, $for = 'email_verification', array $options = []) { $expireAfter = data_get($options, 'expireAfter'); + $meta = (array) data_get($options, 'meta', []); $verificationCode = static::generateFor($subject, $for, false); $verificationCode->expires_at = $expireAfter === null ? Carbon::now()->addHour() : $expireAfter; - $verificationCode->meta = data_get($options, 'meta', []); + $verificationCode->meta = $meta; $verificationCode->status = data_get($options, 'status', 'active'); + $verificationCode->forceFill(static::tracingAttributes( + data_get($options, 'company_uuid') ?? session('company') ?? data_get($subject, 'company_uuid'), + 'email', + (string) (data_get($meta, 'identity') ?? data_get($subject, 'email') ?? '') + )); $verificationCode->save(); if (isset($subject->email)) { @@ -292,16 +412,43 @@ public static function generateSmsVerificationFor($subject, $for = 'phone_verifi $meta['trace'] = $reservation->trace(); } + // With `hashed`, the row keeps an HMAC of the code (as issue() does) and the plain + // code exists only in the message and on the returned instance's `plainCode`. + $plainCode = null; + if (data_get($options, 'hashed')) { + $plainCode = (string) random_int(100000, 999999); + $meta['hashed'] = true; + $meta['attempts'] = 0; + } + + $identity = (string) (data_get($meta, 'identity') ?? data_get($meta, 'phone') ?? $destination ?? ''); $verificationCode = static::generateFor($subject, $for, false); - $verificationCode->expires_at = $expireAfter === null ? Carbon::now()->addHour() : $expireAfter; + $verificationCode->expires_at = $expireAfter === null ? ($plainCode !== null ? Carbon::now()->addMinutes(10) : Carbon::now()->addHour()) : $expireAfter; $verificationCode->meta = $meta; + $verificationCode->status = data_get($options, 'status', $plainCode !== null ? 'active' : $verificationCode->status); + if ($plainCode !== null) { + $verificationCode->code = static::hashCode($plainCode); + } + $verificationCode->forceFill(static::tracingAttributes($companyUuid, 'sms', $identity, $reservation)); $verificationCode->save(); + $verificationCode->plainCode = $plainCode; + + if ($reservation && $reservation->sendUuid) { + SmsLedger::update($reservation->sendUuid, ['verification_code_uuid' => $verificationCode->uuid]); + } + + // Get message. A message callback reads `->code` off the instance it is given; for + // a hashed code it gets a copy carrying the plain code, never the stored HMAC. + $forMessage = $verificationCode; + if ($plainCode !== null) { + $forMessage = clone $verificationCode; + $forMessage->code = $plainCode; + } - // Get message - $message = 'Your ' . config('app.name') . ' verification code is ' . $verificationCode->code; + $message = 'Your ' . config('app.name') . ' verification code is ' . $forMessage->code; $messageCallback = data_get($options, 'messageCallback'); if (is_callable($messageCallback)) { - $message = $messageCallback($verificationCode); + $message = $messageCallback($forMessage); } // SMS service options, including the reservation so the send is counted once diff --git a/src/Notifications/Channels/SmsServiceChannel.php b/src/Notifications/Channels/SmsServiceChannel.php new file mode 100644 index 00000000..cb4b744d --- /dev/null +++ b/src/Notifications/Channels/SmsServiceChannel.php @@ -0,0 +1,61 @@ +destinationFor($notifiable, $notification); + if (blank($to)) { + return; + } + + $message = method_exists($notification, 'toSms') ? $notification->toSms($notifiable) : null; + $text = is_object($message) ? (string) ($message->content ?? '') : (string) $message; + if ($text === '') { + return; + } + + $options = [ + 'purpose' => $notification->smsPurpose ?? 'notification:' . class_basename($notification), + 'company_uuid' => $notification->smsCompanyUuid ?? data_get($notifiable, 'company_uuid'), + ]; + + ($this->service ?? app(SmsService::class))->send((string) $to, $text, $options); + } + + protected function destinationFor($notifiable, Notification $notification): ?string + { + if (is_object($notifiable) && method_exists($notifiable, 'routeNotificationFor')) { + foreach (['sms', 'twilio'] as $driver) { + $to = $notifiable->routeNotificationFor($driver, $notification); + if (filled($to)) { + return (string) $to; + } + } + } + + $phone = data_get($notifiable, 'phone'); + + return filled($phone) ? (string) $phone : null; + } +} diff --git a/src/Notifications/SmsGuardAlert.php b/src/Notifications/SmsGuardAlert.php index e83761ad..94d6a48a 100644 --- a/src/Notifications/SmsGuardAlert.php +++ b/src/Notifications/SmsGuardAlert.php @@ -47,6 +47,8 @@ public static function for(string $signal, SmsContext $context, array $data = [] 'distinct_destinations' => ['burst of SMS to new numbers', 'One tenant texted ' . ($data['distinct_destinations'] ?? '?') . ' distinct numbers inside ' . ($data['window_minutes'] ?? 10) . ' minutes. This is the SMS pumping pattern.'], 'company_budget_threshold' => ['tenant at ' . ($data['percent'] ?? '?') . '% of its daily SMS budget', 'The tenant has sent ' . ($data['sends_today'] ?? '?') . ' of ' . ($data['budget'] ?? '?') . ' messages today.'], 'platform_budget_threshold' => ['platform at ' . ($data['percent'] ?? '?') . '% of its hourly SMS budget', 'All tenants together have sent ' . ($data['sends_this_hour'] ?? '?') . ' of ' . ($data['budget'] ?? '?') . ' messages this hour.'], + 'new_destination_country' => ['SMS to a country new to the tenant', 'The tenant sent ' . ($data['sends_to_country'] ?? '?') . ' messages to ' . ($data['country'] ?? '?') . ' inside ' . ($data['window_minutes'] ?? 10) . ' minutes, a country it has not texted in the last 30 days (known: ' . implode(', ', (array) ($data['known_countries'] ?? [])) . ').'], + 'low_verification_conversion' => ['SMS codes going unverified', 'The tenant had ' . ($data['issued_last_hour'] ?? '?') . ' codes sent by SMS in the last hour and ' . ($data['verified_last_hour'] ?? '?') . ' verified. Real customers verify most of what they ask for.'], default => ['SMS guard signal: ' . $signal, 'The SMS guard raised ' . $signal . '.'], }; diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php index a7392c06..4abaaaa3 100644 --- a/src/Providers/CoreServiceProvider.php +++ b/src/Providers/CoreServiceProvider.php @@ -106,6 +106,7 @@ class CoreServiceProvider extends ServiceProvider \Fleetbase\Console\Commands\SmsGuardMode::class, \Fleetbase\Console\Commands\SmsGuardPause::class, \Fleetbase\Console\Commands\SmsGuardUnpause::class, + \Fleetbase\Console\Commands\SmsLedgerPrune::class, ]; /** diff --git a/src/Support/Sms/SmsBreaker.php b/src/Support/Sms/SmsBreaker.php index 35c22410..d017ce42 100644 --- a/src/Support/Sms/SmsBreaker.php +++ b/src/Support/Sms/SmsBreaker.php @@ -2,9 +2,12 @@ namespace Fleetbase\Support\Sms; +use Fleetbase\Models\Company; +use Fleetbase\Models\VerificationCode; use Fleetbase\Notifications\SmsGuardAlert; use Illuminate\Cache\RateLimiter; use Illuminate\Contracts\Cache\Repository as CacheRepository; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Notification; @@ -30,6 +33,8 @@ class SmsBreaker public const SIGNAL_BUDGET_THRESHOLD = 'company_budget_threshold'; public const SIGNAL_DISTINCT_DESTINATIONS = 'distinct_destinations'; public const SIGNAL_PLATFORM_THRESHOLD = 'platform_budget_threshold'; + public const SIGNAL_NEW_COUNTRY = 'new_destination_country'; + public const SIGNAL_LOW_CONVERSION = 'low_verification_conversion'; public const DISTINCT_WINDOW = 600; public const ALERT_WINDOW = 3600; @@ -113,12 +118,41 @@ public function pauses(): array return array_filter(SmsPolicy::settings()['pauses'], fn ($pause) => $pause['until'] === null || $pause['until'] > time()); } + /** + * A scope key for what an operator typed: "platform", a scope key as is, or a company + * uuid or public id resolved to its company scope. Null when no company matches. + */ + public static function resolveScope(string $input): ?string + { + $input = trim($input); + + if ($input === self::SCOPE_PLATFORM || str_starts_with($input, 'company:') || str_starts_with($input, 'tenant:')) { + return $input; + } + + try { + $company = Company::withoutGlobalScopes() + ->where('uuid', $input) + ->orWhere('public_id', $input) + ->first(['uuid']); + } catch (\Throwable $e) { + return null; + } + + return $company ? 'company:' . $company->uuid : null; + } + + public static function companyUuidFromScope(string $scope): ?string + { + return str_starts_with($scope, 'company:') ? substr($scope, strlen('company:')) : null; + } + /** * Read the counters after a reserved send and trip whatever crossed a line. * * @param array $counters the guard's counters for this send */ - public function observe(SmsContext $context, array $limits, array $counters, RateLimiter $limiter, bool $monitor, array $settings): void + public function observe(SmsContext $context, array $limits, array $counters, RateLimiter $limiter, bool $monitor, array $settings, ?string $country = null): void { $breaker = $settings['breaker']; @@ -128,11 +162,89 @@ public function observe(SmsContext $context, array $limits, array $counters, Rat if (!$limits['trusted']) { $this->observeDistinctDestinations($context, $breaker, $monitor); + $this->observeNewCountry($context, $country, $breaker, $monitor); + $this->observeConversion($context, $breaker, $monitor); } $this->observePlatform($context, $counters, $limiter, $breaker, $monitor); } + /** + * A burst of sends to a country the company has never texted. Learned from the ledger + * rather than configured, since tenants' countries cannot be guessed; armed only once + * the company has enough history for "never" to mean something. + */ + protected function observeNewCountry(SmsContext $context, ?string $country, array $breaker, bool $monitor): void + { + $threshold = $breaker['new_country_min_sends'] ?? null; + if (!$threshold || !$country || !$context->companyUuid) { + return; + } + + if (SmsLedger::sentCountFor($context->companyUuid) < ($breaker['new_country_min_history'] ?? 0)) { + return; + } + + $known = SmsLedger::countriesFor($context->companyUuid); + if (!$known || in_array(strtoupper($country), $known, true)) { + return; + } + + $scope = $context->tenantScope() ?? $context->companyScope(); + $count = $this->increment('new-country:' . $scope . ':' . strtoupper($country) . ':' . intdiv(time(), self::DISTINCT_WINDOW), self::DISTINCT_WINDOW); + if ($count >= $threshold) { + $this->trip($scope, self::SIGNAL_NEW_COUNTRY, $context, $breaker, $monitor, ['country' => strtoupper($country), 'sends_to_country' => $count, 'known_countries' => $known, 'window_minutes' => self::DISTINCT_WINDOW / 60]); + } + } + + /** + * Codes issued by SMS in the last hour against codes actually verified. Real customers + * verify most of what they ask for; pumping verifies nothing. + */ + protected function observeConversion(SmsContext $context, array $breaker, bool $monitor): void + { + $minIssued = $breaker['conversion_min_issued'] ?? null; + if (!$minIssued || !$context->companyUuid) { + return; + } + + $stats = $this->conversionFor($context->companyUuid); + if ($stats === null || $stats['issued'] < $minIssued) { + return; + } + + if ($stats['verified'] * 100 < $stats['issued'] * ($breaker['conversion_min_percent'] ?? 0)) { + $this->trip($context->companyScope(), self::SIGNAL_LOW_CONVERSION, $context, $breaker, $monitor, ['issued_last_hour' => $stats['issued'], 'verified_last_hour' => $stats['verified']]); + } + } + + /** + * Codes issued by SMS and verified for a company in the last hour, cached for a minute; + * null until the verification code table carries the tracing columns. + * + * @return array{issued: int, verified: int}|null + */ + protected function conversionFor(string $companyUuid): ?array + { + if (!VerificationCode::supportsTracingColumns()) { + return null; + } + + try { + return $this->cache->remember($this->key('conversion:' . $companyUuid), 60, function () use ($companyUuid) { + $since = Carbon::now()->subHour(); + $base = VerificationCode::withoutGlobalScopes()->where('company_uuid', $companyUuid)->where('channel', 'sms')->where('created_at', '>=', $since); + + return [ + 'issued' => (int) (clone $base)->count(), + 'verified' => (int) (clone $base)->whereNotNull('consumed_at')->count(), + ]; + }); + } catch (\Throwable $e) { + return null; + } + } + protected function observeCompanyBudget(SmsContext $context, array $counters, RateLimiter $limiter, array $breaker, bool $monitor): void { $scope = $context->companyScope(); diff --git a/src/Support/Sms/SmsGuard.php b/src/Support/Sms/SmsGuard.php index a8fc0f71..b783261f 100644 --- a/src/Support/Sms/SmsGuard.php +++ b/src/Support/Sms/SmsGuard.php @@ -3,6 +3,7 @@ namespace Fleetbase\Support\Sms; use Fleetbase\Exceptions\SmsBlockedException; +use Fleetbase\Models\SmsSend; use Illuminate\Cache\RateLimiter; use Illuminate\Contracts\Cache\Repository as CacheRepository; use Illuminate\Support\Arr; @@ -103,7 +104,7 @@ public function reserve(SmsContext $context): SmsReservation $this->limiter()->hit($counter['key'], $counter['decay']); } - $this->breaker()->observe($context, $limits, $counters, $this->limiter(), $monitor, $settings); + $this->breaker()->observe($context, $limits, $counters, $this->limiter(), $monitor, $settings, $validation['country']); } catch (SmsBlockedException $e) { throw $e; } catch (\Throwable $e) { @@ -116,10 +117,24 @@ public function reserve(SmsContext $context): SmsReservation throw SmsBlockedException::unavailable($context->toArray()); } - return new SmsReservation($context, SmsReservation::DECISION_DEGRADED, null, $validation['country'], $validation['type']); + return $this->ledgered(new SmsReservation($context, SmsReservation::DECISION_DEGRADED, null, $validation['country'], $validation['type'])); } - return new SmsReservation($context, $wouldBlock === null ? SmsReservation::DECISION_ALLOWED : SmsReservation::DECISION_MONITOR, $wouldBlock, $validation['country'], $validation['type']); + return $this->ledgered(new SmsReservation($context, $wouldBlock === null ? SmsReservation::DECISION_ALLOWED : SmsReservation::DECISION_MONITOR, $wouldBlock, $validation['country'], $validation['type'])); + } + + /** + * Put a send that is going ahead on the ledger as reserved; settle() records how it ended. + */ + protected function ledgered(SmsReservation $reservation): SmsReservation + { + $reservation->sendUuid = SmsLedger::record($reservation->context, SmsSend::STATUS_RESERVED, [ + 'destination_country' => $reservation->country, + 'destination_type' => $reservation->type, + 'meta' => array_filter(['decision' => $reservation->decision, 'would_block' => $reservation->wouldBlock]), + ]); + + return $reservation; } /** @@ -144,6 +159,13 @@ public function settle(SmsReservation $reservation, bool $success, array $detail } else { Log::warning('sms.failed', $entry); } + + SmsLedger::update($reservation->sendUuid, [ + 'status' => $success ? SmsSend::STATUS_SENT : SmsSend::STATUS_FAILED, + 'provider' => $details['provider'] ?? null, + 'provider_message_id' => isset($details['message_id']) ? (string) $details['message_id'] : null, + 'error' => $details['error'] ?? null, + ]); } public function breaker(): SmsBreaker @@ -258,6 +280,7 @@ protected function refuse(SmsContext $context, SmsBlockedException $exception, b } Log::warning('sms.blocked', $entry); + SmsLedger::record($context, SmsSend::STATUS_BLOCKED, ['block_reason' => $exception->reason()]); throw $exception; } diff --git a/src/Support/Sms/SmsLedger.php b/src/Support/Sms/SmsLedger.php new file mode 100644 index 00000000..da13281c --- /dev/null +++ b/src/Support/Sms/SmsLedger.php @@ -0,0 +1,193 @@ + $context->companyUuid, + 'tenant_type' => $context->tenantType, + 'tenant_uuid' => $context->tenantUuid, + 'purpose' => $context->purpose, + 'source' => $context->source, + 'destination' => $context->destination, + 'destination_hash' => self::hash($context->destination), + 'status' => $status, + 'subject_type' => $context->subjectType, + 'subject_uuid' => $context->subjectUuid, + 'ip_address' => $context->ip, + 'user_agent' => $context->userAgent, + ], $attributes)); + + return $send->uuid; + } catch (\Throwable $e) { + self::markUnavailable($e); + + return null; + } + } + + /** + * Update a row with how the send ended. + */ + public static function update(?string $uuid, array $attributes): void + { + if (!$uuid || !self::available()) { + return; + } + + try { + SmsSend::where('uuid', $uuid)->update(array_merge($attributes, ['updated_at' => Carbon::now()])); + } catch (\Throwable $e) { + self::markUnavailable($e); + } + } + + /** + * Countries a company has sent to in the last `$days`, cached for ten minutes. + * + * @return array + */ + public static function countriesFor(string $companyUuid, int $days = 30): array + { + if (!self::available()) { + return []; + } + + try { + return Cache::remember('sms-guard:countries:' . $companyUuid, 600, function () use ($companyUuid, $days) { + return SmsSend::where('company_uuid', $companyUuid) + ->where('status', SmsSend::STATUS_SENT) + ->where('created_at', '>=', Carbon::now()->subDays($days)) + ->whereNotNull('destination_country') + ->distinct() + ->pluck('destination_country') + ->map(fn ($country) => strtoupper((string) $country)) + ->values() + ->all(); + }); + } catch (\Throwable $e) { + return []; + } + } + + /** + * How many sends a company has on the ledger in the last `$days`, cached for ten minutes. + */ + public static function sentCountFor(string $companyUuid, int $days = 30): int + { + if (!self::available()) { + return 0; + } + + try { + return (int) Cache::remember('sms-guard:sent-count:' . $companyUuid, 600, function () use ($companyUuid, $days) { + return SmsSend::where('company_uuid', $companyUuid) + ->where('status', SmsSend::STATUS_SENT) + ->where('created_at', '>=', Carbon::now()->subDays($days)) + ->count(); + }); + } catch (\Throwable $e) { + return 0; + } + } + + /** + * Apply retention: mask the full number on rows older than `$maskAfterDays` (the hash + * and the last four digits stay, so per-number limits and forensics still work), and + * delete rows older than `$retentionDays`. + * + * @return array{masked: int, deleted: int} + */ + public static function prune(int $retentionDays, int $maskAfterDays): array + { + $masked = 0; + $deleted = 0; + + if ($retentionDays > 0) { + $deleted = SmsSend::where('created_at', '<', Carbon::now()->subDays($retentionDays))->delete(); + } + + if ($maskAfterDays > 0) { + $rows = SmsSend::where('created_at', '<', Carbon::now()->subDays($maskAfterDays)) + ->whereNotNull('destination') + ->where('destination', 'not like', '•%') + ->get(['uuid', 'destination']); + + foreach ($rows as $row) { + SmsSend::where('uuid', $row->uuid)->update(['destination' => self::mask($row->destination)]); + $masked++; + } + } + + return ['masked' => $masked, 'deleted' => (int) $deleted]; + } + + /** + * `•••4821`: the last four digits. + */ + public static function mask(string $destination): string + { + return '•••' . substr(preg_replace('/\D/', '', $destination) ?? '', -4); + } + + public static function hash(string $destination): string + { + return hash('sha256', PhoneValidator::normalize($destination)); + } + + /** + * Whether the ledger can be written: a database is bound and the table did not fail + * within the last minute. + */ + public static function available(): bool + { + if (Model::getConnectionResolver() === null) { + return false; + } + + try { + return !Cache::has(self::UNAVAILABLE_KEY); + } catch (\Throwable $e) { + return true; + } + } + + private static function markUnavailable(\Throwable $e): void + { + Log::warning('sms.ledger_unavailable', ['error' => $e->getMessage()]); + + try { + Cache::put(self::UNAVAILABLE_KEY, 1, 60); + } catch (\Throwable $ignored) { + // nothing to remember it in; the next write tries again + } + } +} diff --git a/src/Support/Sms/SmsPolicy.php b/src/Support/Sms/SmsPolicy.php index 0fda966e..bc88eace 100644 --- a/src/Support/Sms/SmsPolicy.php +++ b/src/Support/Sms/SmsPolicy.php @@ -222,6 +222,14 @@ public static function normalize(array $settings): array 'alert_percentages' => array_values(array_filter(array_map('intval', static::list($breaker['alert_percentages'] ?? [50, 80])), fn ($pct) => $pct > 0 && $pct < 100)), 'distinct_destinations_per_10m' => static::limit($breaker['distinct_destinations_per_10m'] ?? 25), 'auto_pauses_before_manual' => max(1, (int) ($breaker['auto_pauses_before_manual'] ?? 2)), + 'new_country_min_sends' => static::limit($breaker['new_country_min_sends'] ?? 10), + 'new_country_min_history' => max(0, (int) ($breaker['new_country_min_history'] ?? 50)), + 'conversion_min_issued' => static::limit($breaker['conversion_min_issued'] ?? 20), + 'conversion_min_percent' => max(0, min(100, (int) ($breaker['conversion_min_percent'] ?? 10))), + ], + 'ledger' => [ + 'retention_days' => max(0, (int) data_get($settings, 'ledger.retention_days', 90)), + 'mask_after_days' => max(0, (int) data_get($settings, 'ledger.mask_after_days', 30)), ], 'purposes' => $purposes, 'overrides' => array_values($overrides), diff --git a/src/Support/Sms/SmsReservation.php b/src/Support/Sms/SmsReservation.php index 92a27745..cfa66aec 100644 --- a/src/Support/Sms/SmsReservation.php +++ b/src/Support/Sms/SmsReservation.php @@ -25,6 +25,9 @@ final class SmsReservation public bool $settled = false; + /** the ledger row for this send, when the ledger could be written */ + public ?string $sendUuid = null; + public function __construct( public readonly SmsContext $context, public readonly string $decision = self::DECISION_ALLOWED, @@ -62,6 +65,7 @@ public function trace(): array 'destination_country' => $this->country, 'decision' => $this->decision, 'would_block' => $this->wouldBlock, + 'sms_send_uuid' => $this->sendUuid, ], fn ($value) => $value !== null); } @@ -72,6 +76,7 @@ public function toArray(): array 'would_block' => $this->wouldBlock, 'destination_country' => $this->country, 'destination_type' => $this->type, + 'sms_send_uuid' => $this->sendUuid, ]); } } diff --git a/src/routes.php b/src/routes.php index 9242e3d1..4f325624 100644 --- a/src/routes.php +++ b/src/routes.php @@ -280,6 +280,16 @@ function ($router, $controller) { $router->get('consumers', 'RateLimitController@consumers'); $router->post('consumers/{signature}/reset', 'RateLimitController@resetConsumer'); }); + $router->group(['prefix' => 'sms-guard'], function ($router) { + $router->get('settings', 'SmsGuardController@getSettings'); + $router->post('settings', 'SmsGuardController@saveSettings'); + $router->delete('settings', 'SmsGuardController@resetSettings'); + $router->get('usage', 'SmsGuardController@usage'); + $router->get('ledger', 'SmsGuardController@ledger'); + $router->get('pauses', 'SmsGuardController@pauses'); + $router->post('pauses', 'SmsGuardController@pause'); + $router->delete('pauses/{scope}', 'SmsGuardController@unpause')->where('scope', '.*'); + }); $router->fleetbaseRoutes('schedule-monitor', null, [], function ($router, $controller) { $router->get('tasks', $controller('tasks')); $router->get('{id}/logs', $controller('logs')); diff --git a/tests/Fixtures/Support/SmsGuardFixture.php b/tests/Fixtures/Support/SmsGuardFixture.php new file mode 100644 index 00000000..4f61a2a2 --- /dev/null +++ b/tests/Fixtures/Support/SmsGuardFixture.php @@ -0,0 +1,135 @@ + 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]; + + $container = bind_test_container(array_merge([ + 'database.default' => 'mysql', + 'database.connections.mysql' => $connection, + 'fleetbase.connection.db' => 'mysql', + ], $config)); + $container->instance('cache', new CacheRepository(new ArrayStore())); + Facade::clearResolvedInstances(); + + $capsule = new Capsule($container); + $capsule->addConnection($connection, 'mysql'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + $capsule->getDatabaseManager()->setDefaultConnection('mysql'); + $container->instance('db', $capsule->getDatabaseManager()); + Facade::clearResolvedInstance('db'); + + if ($withLedger) { + self::smsSendsSchema($capsule); + } + + return $capsule; + } + + public static function smsSendsSchema(Capsule $capsule, string $connection = 'mysql'): void + { + $capsule->getConnection($connection)->getSchemaBuilder()->create('sms_sends', function ($table) { + $table->string('uuid')->primary(); + $table->string('company_uuid')->nullable(); + $table->string('tenant_type')->nullable(); + $table->string('tenant_uuid')->nullable(); + $table->string('purpose'); + $table->string('source'); + $table->string('destination')->nullable(); + $table->string('destination_hash')->nullable(); + $table->string('destination_country')->nullable(); + $table->string('destination_type')->nullable(); + $table->string('status'); + $table->string('block_reason')->nullable(); + $table->string('provider')->nullable(); + $table->string('provider_message_id')->nullable(); + $table->text('error')->nullable(); + $table->string('subject_type')->nullable(); + $table->string('subject_uuid')->nullable(); + $table->string('verification_code_uuid')->nullable(); + $table->string('ip_address')->nullable(); + $table->string('user_agent')->nullable(); + $table->text('meta')->nullable(); + $table->timestamps(); + }); + } + + /** + * The verification_codes table as it looks after the October 2026 tracing migration. + */ + public static function verificationCodesSchema(Capsule $capsule, string $connection = 'mysql'): void + { + $capsule->getConnection($connection)->getSchemaBuilder()->create('verification_codes', function ($table) { + $table->string('uuid')->primary(); + $table->string('subject_uuid')->nullable(); + $table->string('subject_type')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('identity')->nullable(); + $table->string('channel')->nullable(); + $table->string('ip_address')->nullable(); + $table->string('user_agent')->nullable(); + $table->string('sms_send_uuid')->nullable(); + $table->string('code')->nullable(); + $table->string('for')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('consumed_at')->nullable(); + $table->text('meta')->nullable(); + $table->string('status')->nullable(); + $table->timestamp('deleted_at')->nullable(); + $table->timestamps(); + }); + } + + public static function companiesSchema(Capsule $capsule, array $rows = [], string $connection = 'mysql'): void + { + $capsule->getConnection($connection)->getSchemaBuilder()->create('companies', function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable(); + $table->string('name')->nullable(); + $table->string('slug')->nullable(); + $table->text('options')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + if ($rows) { + $capsule->getConnection($connection)->table('companies')->insert($rows); + } + } + + public static function settingsSchema(Capsule $capsule, string $connection = 'mysql'): void + { + $capsule->getConnection($connection)->getSchemaBuilder()->create('settings', function ($table) { + $table->increments('id'); + $table->string('key')->unique(); + $table->text('value')->nullable(); + }); + } +} diff --git a/tests/Unit/Console/SmsLedgerPruneTest.php b/tests/Unit/Console/SmsLedgerPruneTest.php new file mode 100644 index 00000000..a9ca1c9a --- /dev/null +++ b/tests/Unit/Console/SmsLedgerPruneTest.php @@ -0,0 +1,61 @@ + 90, 'sms.guard.ledger.mask_after_days' => 30]); + Carbon::setTestNow(Carbon::parse('2026-10-13 12:00:00', 'UTC')); + + $context = new SmsContext('driver_login', '+447400100777', 'company-1'); + $old = SmsLedger::record($context, 'sent'); + $gone = SmsLedger::record($context, 'sent'); + SmsSend::where('uuid', $old)->update(['created_at' => Carbon::now()->subDays(40)]); + SmsSend::where('uuid', $gone)->update(['created_at' => Carbon::now()->subDays(100)]); + + $command = new SmsLedgerPrune(); + $command->setLaravel(app()); + $tester = new CommandTester($command); + $tester->execute([]); + $maskedDestination = SmsSend::where('uuid', $old)->value('destination'); + + $explicit = new SmsLedgerPrune(); + $explicit->setLaravel(app()); + $explicitTester = new CommandTester($explicit); + $explicitTester->execute(['--days' => '10', '--mask-after' => '0']); + + expect($tester->getStatusCode())->toBe(0) + ->and($tester->getDisplay())->toContain('masked 1 row(s) older than 30 day(s), deleted 1 row(s) older than 90 day(s)') + ->and($maskedDestination)->toBe('•••0777') + ->and($explicitTester->getStatusCode())->toBe(0) + ->and($explicitTester->getDisplay())->toContain('deleted 1 row(s) older than 10 day(s)') + ->and(SmsSend::count())->toBe(0); +}); + +test('sms:ledger:prune fails cleanly without the ledger table', function () { + $capsule = SmsGuardFixture::database(); + $capsule->getConnection('mysql')->getSchemaBuilder()->drop('sms_sends'); + + $command = new SmsLedgerPrune(); + $command->setLaravel(app()); + $tester = new CommandTester($command); + $tester->execute([]); + + expect($tester->getStatusCode())->toBe(1) + ->and($tester->getDisplay())->toContain('The SMS ledger could not be pruned'); +}); diff --git a/tests/Unit/Http/AuthControllerVerificationTest.php b/tests/Unit/Http/AuthControllerVerificationTest.php index 63d1e919..c862e0ed 100644 --- a/tests/Unit/Http/AuthControllerVerificationTest.php +++ b/tests/Unit/Http/AuthControllerVerificationTest.php @@ -533,182 +533,3 @@ function auth_controller_verification_phone_key(string $phone): string ->and($user->last_login)->toBeNull(); } })->with([false, true]); - -test('verify sms code returns success and deletes the consumed verification key', function () { - $redis = auth_controller_verification_fixtures(); - $key = auth_controller_verification_phone_key('+15555550123'); - $redis->values[$key] = '765432'; - - $response = (new AuthController())->verifySmsCode(auth_controller_verification_request([ - 'phone' => '+15555550123', - 'code' => '765432', - ])); - - expect($response->getStatusCode())->toBe(200) - ->and($response->getData(true))->toBe([ - 'status' => 'OK', - 'message' => 'Code verified', - ]) - ->and($redis->deleted)->toBe([$key]) - ->and($redis->values)->not->toHaveKey($key); -}); - -test('verify sms code rejects mismatched codes without deleting the stored code', function () { - $redis = auth_controller_verification_fixtures(); - $key = auth_controller_verification_phone_key('+15555550123'); - $redis->values[$key] = '765432'; - - $response = (new AuthController())->verifySmsCode(auth_controller_verification_request([ - 'phone' => '+15555550123', - 'code' => '000000', - ])); - - expect($response->getStatusCode())->toBe(400) - ->and($response->getData(true))->toBe([ - 'errors' => ['Invalid verification code'], - ]) - ->and($redis->deleted)->toBe([]) - ->and($redis->values[$key])->toBe('765432'); -}); - -test('send verification sms requires a matching user and honors the driver filter', function () { - [$capsule] = auth_controller_verification_database(); - auth_controller_verification_insert_user($capsule, [ - 'type' => 'user', - ]); - - $missingUser = (new AuthController())->sendVerificationSms(auth_controller_verification_request([ - 'phone' => '555550123', - 'countryCode' => '1', - ])); - $wrongType = (new AuthController())->sendVerificationSms(auth_controller_verification_request([ - 'phone' => '5555550123', - 'countryCode' => '1', - 'driver' => 'driver', - ])); - - expect($missingUser->getStatusCode())->toBe(400) - ->and($missingUser->getData(true))->toBe([ - 'errors' => ['No user with this phone # found.'], - ]) - ->and($wrongType->getStatusCode())->toBe(400) - ->and($wrongType->getData(true))->toBe([ - 'errors' => ['No user with this phone # found.'], - ]); -}); - -test('send verification sms stores a ttl backed code and reports twilio failures', function () { - [$capsule, $redis, , $twilio] = auth_controller_verification_database(); - auth_controller_verification_insert_user($capsule, [ - 'type' => 'driver', - ]); - - $success = (new AuthController())->sendVerificationSms(auth_controller_verification_request([ - 'phone' => '5555550123', - 'countryCode' => '1', - 'driver' => 'driver', - ])); - $key = auth_controller_verification_phone_key('+15555550123'); - - $twilio->exception = new RuntimeException('twilio unavailable'); - $failure = (new AuthController())->sendVerificationSms(auth_controller_verification_request([ - 'phone' => '+15555550123', - 'countryCode' => '1', - ])); - - expect($success->getStatusCode())->toBe(200) - ->and($success->getData(true))->toBe(['status' => 'OK']) - ->and($twilio->messages)->toHaveCount(1) - ->and($twilio->messages[0]['to'])->toBe('+15555550123') - ->and($twilio->messages[0]['message'])->toStartWith('Your Fleetbase authentication code is ') - ->and($redis->ttls[$key])->toBe(600) - ->and($redis->values[$key])->toBeInt() - ->and($failure->getStatusCode())->toBe(400) - ->and($failure->getData(true))->toBe(['error' => 'twilio unavailable']); -}); - -test('authenticate sms code rejects invalid otp before querying users or deleting redis state', function () { - $redis = auth_controller_verification_fixtures([ - 'fleetbase.sms_auth_bypass_code' => '246810', - ]); - $key = auth_controller_verification_phone_key('+19765550123'); - $redis->values[$key] = '135790'; - - $response = (new AuthController())->authenticateSmsCode(auth_controller_verification_request([ - 'phone' => '9765550123', - 'countryCode' => '1', - 'code' => '000000', - ])); - - expect($response->getStatusCode())->toBe(400) - ->and($response->getData(true))->toBe([ - 'errors' => ['Invalid verification code'], - ]) - ->and($redis->deleted)->toBe([]) - ->and($redis->values[$key])->toBe('135790'); -}); - -test('authenticate sms code logs in matching users and consumes one time codes', function () { - [$capsule, $redis] = auth_controller_verification_database(); - auth_controller_verification_insert_user($capsule, [ - 'status' => 'active', - ]); - $auth = new AuthControllerVerificationAuthFake(); - Illuminate\Support\Facades\Auth::swap($auth); - - $key = auth_controller_verification_phone_key('+15555550123'); - $redis->values[$key] = '135790'; - - $response = (new AuthController())->authenticateSmsCode(auth_controller_verification_request([ - 'phone' => '5555550123', - 'countryCode' => '1', - 'code' => '135790', - ])); - $payload = $response->getData(true); - - expect($response->getStatusCode())->toBe(200) - ->and($payload['token'])->toContain('|') - ->and($payload['user']['uuid'])->toBe('verification-user') - ->and($auth->loggedIn)->toBe(['verification-user']) - ->and($redis->deleted)->toBe([$key]) - ->and($redis->values)->not->toHaveKey($key); -}); - -test('authenticate sms code reports token creation failures after consuming a valid otp', function () { - [$capsule, $redis] = auth_controller_verification_database(); - auth_controller_verification_insert_user($capsule, [ - 'status' => 'active', - ]); - Illuminate\Support\Facades\Auth::swap(new AuthControllerVerificationAuthFake()); - - $key = auth_controller_verification_phone_key('+15555550123'); - $redis->values[$key] = '135790'; - $capsule->getConnection('mysql')->getSchemaBuilder()->drop('personal_access_tokens'); - - $response = (new AuthController())->authenticateSmsCode(auth_controller_verification_request([ - 'phone' => '5555550123', - 'countryCode' => '1', - 'code' => '135790', - ])); - - expect($response->getStatusCode())->toBe(400) - ->and($response->getData(true)['errors'][0])->toContain('personal_access_tokens') - ->and($redis->deleted)->toBe([$key]) - ->and($redis->values)->not->toHaveKey($key); -}); - -test('authenticate sms code reports authentication failure when a valid otp no longer matches a user', function () { - [, $redis] = auth_controller_verification_database(); - $key = auth_controller_verification_phone_key('+19995550123'); - $redis->values[$key] = '135790'; - - $response = (new AuthController())->authenticateSmsCode(auth_controller_verification_request([ - 'phone' => '9995550123', - 'countryCode' => '1', - 'code' => '135790', - ])); - - expect($response->getStatusCode())->toBe(401) - ->and($response->getData(true))->toBe('Authentication failed') - ->and($redis->deleted)->toBe([$key]); -}); diff --git a/tests/Unit/Http/SmsGuardControllerTest.php b/tests/Unit/Http/SmsGuardControllerTest.php new file mode 100644 index 00000000..94f0944b --- /dev/null +++ b/tests/Unit/Http/SmsGuardControllerTest.php @@ -0,0 +1,215 @@ + 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]; + + $container = bind_test_container([ + 'sms.guard' => ['enabled' => true, 'mode' => 'enforce', 'limits' => ['company' => ['per_day' => 200]]], + 'database.default' => 'mysql', + 'database.connections.mysql' => $connection, + 'fleetbase.connection.db' => 'mysql', + ]); + $container->instance('cache', new CacheRepository(new ArrayStore())); + app()->forgetInstance(SmsGuard::class); + Facade::clearResolvedInstances(); + + $capsule = new Capsule($container); + $capsule->addConnection($connection, 'mysql'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + $capsule->getDatabaseManager()->setDefaultConnection('mysql'); + $container->instance('db', $capsule->getDatabaseManager()); + Facade::clearResolvedInstance('db'); + + $schema = $capsule->getConnection('mysql')->getSchemaBuilder(); + $schema->create('settings', function ($table) { + $table->increments('id'); + $table->string('key')->unique(); + $table->text('value')->nullable(); + }); + $schema->create('companies', function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable(); + $table->string('name')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + SmsGuardFixture::smsSendsSchema($capsule); + $capsule->getConnection('mysql')->table('companies')->insert([ + ['uuid' => 'company-acme', 'public_id' => 'company_acme', 'name' => 'Acme'], + ['uuid' => 'company-globex', 'public_id' => 'company_globex', 'name' => 'Globex'], + ]); + + $validation = new ValidationFactory(new Translator(new ArrayLoader(), 'en')); + $validation->setPresenceVerifier(new DatabasePresenceVerifier($capsule->getDatabaseManager())); + Request::macro('validate', function (array $rules) use ($validation) { + return $validation->make($this->all(), $rules)->validate(); + }); + + return $capsule; +} + +function sms_guard_controller_request(string $method = 'GET', array $input = []): AdminRequest +{ + return AdminRequest::create('/int/v1/sms-guard/settings', $method, $input); +} + +afterEach(function () { + Carbon::setTestNow(); + + $macros = new ReflectionProperty(Request::class, 'macros'); + $macros->setAccessible(true); + $macros->setValue(null, array_diff_key($macros->getValue(), ['validate' => true])); + + EloquentModel::unsetConnectionResolver(); + EloquentModel::clearBootedModels(); + Facade::clearResolvedInstances(); +}); + +test('sms guard controller returns the effective policy, defaults, pauses and ledger availability', function () { + sms_guard_controller_fixture(); + SmsPolicy::update(['mode' => 'monitor', 'overrides' => [['company_uuid' => 'company-acme', 'per_day' => 2000, 'note' => 'large client']]]); + SmsGuard::make()->breaker()->pause('company:company-globex', 30, 'incident'); + + $payload = (new SmsGuardController())->getSettings(sms_guard_controller_request())->getData(true); + + expect($payload['settings']['mode'])->toBe('monitor') + ->and($payload['settings']['limits']['company']['per_day'])->toBe(200) + ->and($payload['settings']['overrides'][0])->toMatchArray(['company_uuid' => 'company-acme', 'per_day' => 2000, 'note' => 'large client', 'company_id' => 'company_acme', 'company_name' => 'Acme']) + ->and($payload['defaults']['mode'])->toBe('enforce') + ->and($payload['pauses'])->toHaveCount(1) + ->and($payload['pauses'][0])->toMatchArray(['scope' => 'company:company-globex', 'reason' => 'incident', 'company_name' => 'Globex', 'platform' => false]) + ->and($payload['pauses'][0]['until_iso'])->toBeString() + ->and($payload['ledger_available'])->toBeTrue(); +}); + +test('sms guard controller saves a validated policy, replaces the override list and keeps pauses', function () { + sms_guard_controller_fixture(); + SmsGuard::make()->breaker()->pause('platform', null, 'freeze'); + + $saved = (new SmsGuardController())->saveSettings(sms_guard_controller_request('POST', [ + 'enabled' => true, + 'mode' => 'enforce', + 'alert_emails' => ['ops@example.test'], + 'validation' => ['enabled' => true, 'blocked_countries' => ['KP'], 'blocked_prefixes' => ['+881']], + 'limits' => ['company' => ['per_day' => 300, 'per_hour' => null], 'destination' => ['cooldown_seconds' => 30]], + 'breaker' => ['pause_minutes' => 15, 'alert_percentages' => [50], 'new_country_min_sends' => 5], + 'overrides' => [['company_uuid' => 'company-globex', 'unlimited' => true, 'note' => 'partner']], + ]))->getData(true); + + $trimmed = (new SmsGuardController())->saveSettings(sms_guard_controller_request('POST', ['enabled' => true, 'mode' => 'enforce', 'overrides' => []]))->getData(true); + + expect($saved['settings']['alert_emails'])->toBe(['ops@example.test']) + ->and($saved['settings']['validation']['blocked_countries'])->toBe(['KP']) + ->and($saved['settings']['limits']['company'])->toBe(['per_minute' => 10, 'per_hour' => null, 'per_day' => 300]) + ->and($saved['settings']['limits']['destination']['cooldown_seconds'])->toBe(30) + ->and($saved['settings']['breaker']['pause_minutes'])->toBe(15) + ->and($saved['settings']['breaker']['new_country_min_sends'])->toBe(5) + ->and($saved['settings']['overrides'][0])->toMatchArray(['company_uuid' => 'company-globex', 'unlimited' => true, 'company_name' => 'Globex']) + ->and($saved['pauses'][0]['scope'])->toBe('platform') + ->and($trimmed['settings']['overrides'])->toBe([]) + ->and($trimmed['settings']['limits']['company']['per_day'])->toBe(300, 'a save without limits keeps the stored ones') + ->and($trimmed['pauses'][0]['scope'])->toBe('platform', 'saving never drops a pause'); +}); + +test('sms guard controller rejects an unknown organization, a bad mode and a bad email', function () { + sms_guard_controller_fixture(); + + $cases = [ + ['enabled' => true, 'mode' => 'enforce', 'overrides' => [['company_uuid' => 'company-missing', 'per_day' => 5]]], + ['enabled' => true, 'mode' => 'loud'], + ['enabled' => true, 'mode' => 'monitor', 'alert_emails' => ['not-an-email']], + ]; + + foreach ($cases as $input) { + try { + (new SmsGuardController())->saveSettings(sms_guard_controller_request('POST', $input)); + expect(false)->toBeTrue('validation should have failed'); + } catch (ValidationException $e) { + expect($e->errors())->not->toBeEmpty(); + } + } +}); + +test('sms guard controller resets to the defaults and manages pauses through the api', function () { + sms_guard_controller_fixture(); + $controller = new SmsGuardController(); + + $paused = $controller->pause(sms_guard_controller_request('POST', ['scope' => 'company_acme', 'minutes' => 15, 'reason' => 'incident 7']))->getData(true); + $bad = $controller->pause(sms_guard_controller_request('POST', ['scope' => 'company_nobody'])); + $listed = $controller->pauses(sms_guard_controller_request())->getData(true); + $lifted = $controller->unpause(sms_guard_controller_request('DELETE'), 'company%3Acompany-acme')->getData(true); + $badLift = $controller->unpause(sms_guard_controller_request('DELETE'), 'company_nobody'); + + SmsPolicy::update(['mode' => 'monitor']); + $reset = $controller->resetSettings(sms_guard_controller_request('DELETE'))->getData(true); + + expect($paused['pauses'][0])->toMatchArray(['scope' => 'company:company-acme', 'reason' => 'incident 7', 'company_id' => 'company_acme', 'auto' => false]) + ->and($bad->getStatusCode())->toBe(422) + ->and($listed['pauses'])->toHaveCount(1) + ->and($lifted['pauses'])->toBe([]) + ->and($badLift->getStatusCode())->toBe(422) + ->and($reset['settings']['mode'])->toBe('enforce') + ->and(SmsPolicy::stored())->toBe([]); +}); + +test('sms guard controller summarises usage per organization and filters the ledger', function () { + sms_guard_controller_fixture(); + Carbon::setTestNow(Carbon::parse('2026-10-13 12:00:00', 'UTC')); + + $acme = new SmsContext('storefront_create_customer', '+447400100001', 'company-acme', 'store', 'store-1', 'storefront', '203.0.113.9'); + $globex = new SmsContext('driver_login', '+447400100002', 'company-globex', null, null, 'api', '198.51.100.7'); + + SmsLedger::update(SmsLedger::record($acme, 'reserved'), ['status' => 'sent']); + SmsLedger::update(SmsLedger::record($acme, 'reserved'), ['status' => 'sent']); + SmsLedger::record($acme, 'blocked', ['block_reason' => 'cooldown']); + SmsLedger::update(SmsLedger::record($globex, 'reserved'), ['status' => 'failed', 'error' => 'carrier']); + SmsLedger::record(new SmsContext('2fa', '+447400100003', null), 'reserved'); + SmsPolicy::update(['overrides' => [['company_uuid' => 'company-globex', 'unlimited' => true]]]); + SmsGuard::make()->breaker()->pause('company:company-acme', 5, 'test'); + + $usage = (new SmsGuardController())->usage(sms_guard_controller_request('GET', ['hours' => 1]))->getData(true); + $ledger = (new SmsGuardController())->ledger(sms_guard_controller_request('GET', ['status' => 'blocked']))->getData(true); + $byNumber = (new SmsGuardController())->ledger(sms_guard_controller_request('GET', ['destination' => '+44 7400 100002', 'company_uuid' => 'company-globex', 'purpose' => 'driver_login']))->getData(true); + + expect($usage['hours'])->toBe(1) + ->and($usage['ledger_available'])->toBeTrue() + ->and($usage['usage'])->toHaveCount(2) + ->and($usage['usage'][0])->toMatchArray(['company_uuid' => 'company-acme', 'company_name' => 'Acme', 'sent' => 2, 'failed' => 0, 'blocked' => 1, 'destinations' => 1, 'daily_budget' => 200, 'paused' => true]) + ->and($usage['usage'][1])->toMatchArray(['company_uuid' => 'company-globex', 'sent' => 0, 'failed' => 1, 'daily_budget' => null, 'paused' => false]) + ->and($ledger['sends'])->toHaveCount(1) + ->and($ledger['sends'][0]['block_reason'])->toBe('cooldown') + ->and($byNumber['sends'])->toHaveCount(1) + ->and($byNumber['sends'][0]['error'])->toBe('carrier') + ->and(SmsSend::count())->toBe(5); +}); diff --git a/tests/Unit/Models/VerificationCodeTracingTest.php b/tests/Unit/Models/VerificationCodeTracingTest.php new file mode 100644 index 00000000..45c32bd7 --- /dev/null +++ b/tests/Unit/Models/VerificationCodeTracingTest.php @@ -0,0 +1,218 @@ +messages[] = compact('to', 'message'); + + return (object) ['sid' => 'SM-traced']; + } +} + +class VerificationCodeTracingMailerFake +{ + public array $sent = []; + + public function to(mixed $recipient): self + { + return $this; + } + + public function send(mixed $mail): void + { + $this->sent[] = $mail; + } +} + +/** + * A verification_codes table WITH the tracing columns, the ledger table, and a guard on. + */ +function verification_code_tracing_database(bool $guard = true): VerificationCodeTracingTwilioFake +{ + EloquentModel::clearBootedModels(); + session()->flush(); + + $connection = [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]; + + $container = bind_test_container([ + 'app.name' => 'Fleetbase', + 'app.key' => 'base64:test-app-key', + 'sms.default_provider' => 'twilio', + 'sms.guard' => [ + 'enabled' => $guard, + 'limits' => ['destination' => ['cooldown_seconds' => 0, 'per_hour' => null, 'per_day' => null], 'probation' => ['days' => 0]], + 'breaker' => ['alert_percentages' => [], 'distinct_destinations_per_10m' => 500, 'new_country_min_sends' => null, 'conversion_min_issued' => null], + ], + 'database.default' => 'mysql', + 'database.connections.mysql' => $connection, + 'fleetbase.connection.db' => 'mysql', + ]); + $container->instance('cache', new CacheRepository(new ArrayStore())); + $container->instance('request', Request::create('/storefront/v1/customers/request-creation-code', 'POST', [], [], [], ['REMOTE_ADDR' => '203.0.113.9', 'HTTP_USER_AGENT' => 'StorefrontApp/3.1'])); + $container->instance('responsecache', new class { + public function clear(): void + { + } + }); + $twilio = new VerificationCodeTracingTwilioFake(); + $container->instance('twilio', $twilio); + $container->instance('mail.manager', new VerificationCodeTracingMailerFake()); + Facade::clearResolvedInstances(); + + $capsule = new Capsule($container); + $capsule->addConnection($connection, 'mysql'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + $capsule->getDatabaseManager()->setDefaultConnection('mysql'); + $container->instance('db', $capsule->getDatabaseManager()); + Facade::clearResolvedInstance('db'); + + SmsGuardFixture::verificationCodesSchema($capsule); + SmsGuardFixture::companiesSchema($capsule); + SmsGuardFixture::smsSendsSchema($capsule); + + session(['company' => 'company-1', 'storefront_store' => 'store-1']); + + return $twilio; +} + +function verification_code_tracing_subject(array $attributes = []): User +{ + $user = new User(); + $user->setRawAttributes(array_merge(['uuid' => 'user-traced', 'name' => 'Ada Lovelace', 'phone' => '+447400100123', 'email' => 'ada@example.test'], $attributes), true); + + return $user; +} + +afterEach(function () { + Carbon::setTestNow(); + session()->flush(); + EloquentModel::unsetConnectionResolver(); + EloquentModel::clearBootedModels(); + Facade::clearResolvedInstances(); +}); + +it('fills the tracing columns and links the ledger row for an sms code', function () { + $twilio = verification_code_tracing_database(); + + $code = VerificationCode::generateSmsVerificationFor(verification_code_tracing_subject(), 'storefront_create_customer', [ + 'meta' => ['identity' => '+447400100123'], + ]); + + $send = SmsSend::first(); + + expect(VerificationCode::supportsTracingColumns())->toBeTrue() + ->and($code->company_uuid)->toBe('company-1') + ->and($code->identity)->toBe('+447400100123') + ->and($code->channel)->toBe('sms') + ->and($code->ip_address)->toBe('203.0.113.9') + ->and($code->user_agent)->toBe('StorefrontApp/3.1') + ->and($code->sms_send_uuid)->toBe($send->uuid) + ->and($code->consumed_at)->toBeNull() + ->and($send->verification_code_uuid)->toBe($code->uuid) + ->and($send->status)->toBe('sent') + ->and($twilio->messages)->toHaveCount(1); +}); + +it('issues a hashed single-use code when asked, and the message still carries the plain code', function () { + $twilio = verification_code_tracing_database(); + Carbon::setTestNow(Carbon::parse('2026-10-13 10:00:00', 'UTC')); + + $code = VerificationCode::generateSmsVerificationFor(verification_code_tracing_subject(), 'storefront_create_customer', [ + 'hashed' => true, + 'meta' => ['identity' => '+447400100123'], + 'messageCallback' => fn (VerificationCode $verification) => 'Your code is ' . $verification->code, + ]); + + $stored = VerificationCode::withoutGlobalScopes()->where('uuid', $code->uuid)->first(); + $found = VerificationCode::findActiveFor('storefront_create_customer', '+447400100123'); + + expect($code->plainCode)->toMatch('/^\d{6}$/') + ->and($stored->code)->toBe(VerificationCode::hashCode($code->plainCode)) + ->and($stored->code)->not->toBe($code->plainCode) + ->and($stored->meta)->toMatchArray(['identity' => '+447400100123', 'hashed' => true, 'attempts' => 0]) + ->and($stored->status)->toBe('active') + ->and($stored->expires_at->toDateTimeString())->toBe('2026-10-13 10:10:00') + ->and($twilio->messages[0]['message'])->toBe('Your code is ' . $code->plainCode) + ->and($found?->uuid)->toBe($code->uuid) + ->and($found->check('000000'))->toBe(VerificationCode::CHECK_INVALID) + ->and($found->check($code->plainCode))->toBe(VerificationCode::CHECK_VALID); + + $found->consume(); + + expect(VerificationCode::findActiveFor('storefront_create_customer', '+447400100123'))->toBeNull() + ->and($found->fresh()->status)->toBe('used') + ->and($found->fresh()->consumed_at)->not->toBeNull() + ->and($found->fresh()->getMeta('consumed_at'))->not->toBeNull(); +}); + +it('finds live codes by identity or subject, skipping used, locked and expired ones, including legacy rows', function () { + verification_code_tracing_database(false); + $db = EloquentModel::getConnectionResolver()->connection('mysql'); + + $row = fn (string $uuid, string $code, ?string $identity, ?string $subject, ?string $status, Carbon $expires, Carbon $created, ?array $meta = null) => [ + 'uuid' => $uuid, + 'code' => $code, + 'for' => 'storefront_login', + 'identity' => $identity, + 'subject_uuid' => $subject, + 'status' => $status, + 'meta' => $meta ? json_encode($meta) : null, + 'expires_at' => $expires, + 'created_at' => $created, + 'updated_at' => Carbon::now(), + ]; + $db->table('verification_codes')->insert([ + $row('legacy', '111111', null, 'user-1', null, Carbon::now()->addHour(), Carbon::now()->subMinutes(3), ['identity' => '+447400100001']), + $row('used', '222222', '+447400100001', null, 'used', Carbon::now()->addHour(), Carbon::now()->subMinutes(2)), + $row('locked', '333333', '+447400100001', null, 'locked', Carbon::now()->addHour(), Carbon::now()->subMinute()), + $row('expired', '444444', '+447400100001', null, 'active', Carbon::now()->subMinute(), Carbon::now()), + $row('other', '555555', '+447400100002', 'user-2', 'active', Carbon::now()->addHour(), Carbon::now()), + ]); + + expect(VerificationCode::findActiveFor('storefront_login', '+447400100001')?->uuid)->toBe('legacy') + ->and(VerificationCode::findActiveFor('storefront_login', '+447400100001', 'user-1')?->uuid)->toBe('legacy') + ->and(VerificationCode::findActiveFor('storefront_login', '+447400100001', 'user-9'))->toBeNull() + ->and(VerificationCode::findActiveFor('storefront_login', '+447400100002')?->uuid)->toBe('other') + ->and(VerificationCode::findActiveForSubject('storefront_login', 'user-2')?->uuid)->toBe('other') + ->and(VerificationCode::findActiveForSubject('storefront_login', 'user-9'))->toBeNull() + ->and(VerificationCode::findActiveFor('storefront_login', '+447400100001')->check('111111'))->toBe(VerificationCode::CHECK_VALID); +}); + +it('traces email codes too and leaves tracing alone without the guard', function () { + verification_code_tracing_database(false); + + $email = VerificationCode::generateEmailVerificationFor(verification_code_tracing_subject(), 'storefront_create_customer', ['meta' => ['identity' => 'ada@example.test']]); + $sms = VerificationCode::generateSmsVerificationFor(verification_code_tracing_subject(), 'storefront_login'); + + expect($email->channel)->toBe('email') + ->and($email->identity)->toBe('ada@example.test') + ->and($email->company_uuid)->toBe('company-1') + ->and($email->ip_address)->toBe('203.0.113.9') + ->and($sms->channel)->toBe('sms') + ->and($sms->identity)->toBe('+447400100123') + ->and($sms->sms_send_uuid)->toBeNull() + ->and($sms->meta)->toBe([]) + ->and(SmsSend::count())->toBe(0); +}); diff --git a/tests/Unit/Notifications/SmsServiceChannelTest.php b/tests/Unit/Notifications/SmsServiceChannelTest.php new file mode 100644 index 00000000..86918ae4 --- /dev/null +++ b/tests/Unit/Notifications/SmsServiceChannelTest.php @@ -0,0 +1,81 @@ +sent[] = compact('to', 'text', 'options'); + + return ['success' => true]; + } +} + +class SmsServiceChannelTestNotification extends Notification +{ + public string $smsPurpose = 'storefront_order_alert'; + + public function __construct(public mixed $message) + { + } + + public function toSms($notifiable): mixed + { + return $this->message; + } +} + +function sms_service_channel_service(): SmsServiceChannelTestService +{ + bind_test_container(['sms.default_provider' => 'twilio']); + + return new SmsServiceChannelTestService(); +} + +test('sms service channel sends a notification text through SmsService with its purpose', function () { + $service = sms_service_channel_service(); + $channel = new SmsServiceChannel($service); + + $notifiable = new class { + public ?string $company_uuid = 'company-1'; + + public function routeNotificationFor(string $driver, $notification = null): ?string + { + return $driver === 'twilio' ? '+447400100123' : null; + } + }; + + $channel->send($notifiable, new SmsServiceChannelTestNotification('New order!')); + $channel->send($notifiable, new SmsServiceChannelTestNotification((object) ['content' => 'From an object'])); + + expect($service->sent)->toHaveCount(2) + ->and($service->sent[0])->toBe(['to' => '+447400100123', 'text' => 'New order!', 'options' => ['purpose' => 'storefront_order_alert', 'company_uuid' => 'company-1']]) + ->and($service->sent[1]['text'])->toBe('From an object'); +}); + +test('sms service channel falls back to a phone attribute and sends nothing without a number or text', function () { + $service = sms_service_channel_service(); + $channel = new SmsServiceChannel($service); + + $withPhone = new class { + public string $phone = '+447400100124'; + }; + $silent = new class { + public ?string $phone = null; + }; + + $channel->send($withPhone, new SmsServiceChannelTestNotification('Hello')); + $channel->send($silent, new SmsServiceChannelTestNotification('Hello')); + $channel->send($withPhone, new SmsServiceChannelTestNotification('')); + $channel->send($withPhone, new class extends Notification { + }); + + expect($service->sent)->toHaveCount(1) + ->and($service->sent[0]['to'])->toBe('+447400100124') + ->and($service->sent[0]['options']['company_uuid'])->toBeNull(); +}); diff --git a/tests/Unit/Support/Sms/SmsGuardLedgerTest.php b/tests/Unit/Support/Sms/SmsGuardLedgerTest.php new file mode 100644 index 00000000..3b25f9fd --- /dev/null +++ b/tests/Unit/Support/Sms/SmsGuardLedgerTest.php @@ -0,0 +1,260 @@ + */ + public array $alerts = []; + + protected function dispatchAlert(SmsGuardAlert $alert): void + { + $this->alerts[] = $alert; + } +} + +/** + * The guard with a real ledger and verification code table behind it. + * + * @return array{0: SmsGuard, 1: SmsGuardLedgerTestBreaker, 2: Capsule} + */ +function sms_guard_ledger_setup(array $guard = []): array +{ + EloquentModel::clearBootedModels(); + session()->flush(); + + $connection = [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]; + + $config = SmsPolicy::merge([ + 'enabled' => true, + 'mode' => 'enforce', + 'validation' => ['enabled' => true], + 'limits' => [ + 'destination' => ['cooldown_seconds' => 0, 'per_hour' => null, 'per_day' => null], + 'ip' => ['per_15_minutes' => 500, 'per_day' => 500], + 'company' => ['per_minute' => 500, 'per_hour' => 500, 'per_day' => 500], + 'probation' => ['days' => 0], + 'platform' => ['public' => ['per_minute' => 500, 'per_hour' => 500, 'per_day' => 500]], + ], + 'breaker' => [ + 'alert_percentages' => [], + 'distinct_destinations_per_10m' => 500, + 'new_country_min_sends' => 3, + 'new_country_min_history' => 5, + 'conversion_min_issued' => 20, + 'conversion_min_percent' => 10, + ], + ], $guard); + + $container = bind_test_container([ + 'sms.guard' => $config, + 'database.default' => 'mysql', + 'database.connections.mysql' => $connection, + 'fleetbase.connection.db' => 'mysql', + ]); + $cache = new CacheRepository(new ArrayStore()); + $container->instance('cache', $cache); + $container->instance('request', Request::create('/storefront/v1/customers/request-creation-code', 'POST', [], [], [], ['REMOTE_ADDR' => '203.0.113.9'])); + Facade::clearResolvedInstances(); + session(['company' => 'company-1', 'storefront_store' => 'store-1']); + + $capsule = new Capsule($container); + $capsule->addConnection($connection, 'mysql'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + $capsule->getDatabaseManager()->setDefaultConnection('mysql'); + $container->instance('db', $capsule->getDatabaseManager()); + Facade::clearResolvedInstance('db'); + + SmsGuardFixture::smsSendsSchema($capsule); + SmsGuardFixture::verificationCodesSchema($capsule); + + $breaker = new SmsGuardLedgerTestBreaker($cache); + + return [new SmsGuard($cache, $breaker), $breaker, $capsule]; +} + +function sms_guard_ledger_seed_sent(Capsule $capsule, string $company, string $country, int $count): void +{ + $rows = []; + foreach (range(1, $count) as $i) { + $rows[] = [ + 'uuid' => (string) Str::uuid(), + 'company_uuid' => $company, + 'purpose' => 'storefront_create_customer', + 'source' => 'storefront', + 'destination' => '+4474001' . str_pad((string) $i, 5, '0', STR_PAD_LEFT), + 'destination_country' => $country, + 'status' => 'sent', + 'created_at' => Carbon::now()->subDay(), + 'updated_at' => Carbon::now()->subDay(), + ]; + } + $capsule->getConnection('mysql')->table('sms_sends')->insert($rows); +} + +afterEach(function () { + Carbon::setTestNow(); + session()->flush(); + EloquentModel::unsetConnectionResolver(); + EloquentModel::clearBootedModels(); + Facade::clearResolvedInstances(); +}); + +test('an allowed send is reserved on the ledger and settled with the provider outcome', function () { + [$guard] = sms_guard_ledger_setup(); + + $reservation = $guard->reserve(SmsContext::resolve('+44 7400 100123', ['purpose' => 'storefront_create_customer'])); + $guard->settle($reservation, true, ['provider' => 'twilio', 'message_id' => 'SM1']); + + $failed = $guard->reserve(SmsContext::resolve('+447400100124', ['purpose' => 'storefront_create_customer'])); + $guard->settle($failed, false, ['provider' => 'twilio', 'error' => 'carrier refused']); + + $sent = SmsSend::where('uuid', $reservation->sendUuid)->first(); + $lost = SmsSend::where('uuid', $failed->sendUuid)->first(); + + expect($reservation->sendUuid)->toBeString() + ->and($reservation->trace()['sms_send_uuid'])->toBe($reservation->sendUuid) + ->and($sent->status)->toBe('sent') + ->and($sent->company_uuid)->toBe('company-1') + ->and($sent->tenant_uuid)->toBe('store-1') + ->and($sent->destination)->toBe('+447400100123') + ->and($sent->destination_country)->toBe('GB') + ->and($sent->destination_type)->toBe('MOBILE') + ->and($sent->provider_message_id)->toBe('SM1') + ->and($sent->ip_address)->toBe('203.0.113.9') + ->and($sent->meta)->toBe(['decision' => 'allowed']) + ->and($lost->status)->toBe('failed') + ->and($lost->error)->toBe('carrier refused'); +}); + +test('a refused send is written to the ledger as blocked with its reason', function () { + [$guard] = sms_guard_ledger_setup(); + + try { + $guard->reserve(SmsContext::resolve('+4471', ['purpose' => 'storefront_create_customer'])); + } catch (SmsBlockedException $e) { + // expected + } + + $guard->breaker()->pause('company:company-1', 30, 'incident'); + try { + $guard->reserve(SmsContext::resolve('+447400100123', ['purpose' => 'storefront_create_customer'])); + } catch (SmsBlockedException $e) { + // expected + } + + $rows = SmsSend::orderBy('created_at')->get(); + + expect($rows)->toHaveCount(2) + ->and($rows[0]->status)->toBe('blocked') + ->and($rows[0]->block_reason)->toBe('invalid_destination') + ->and($rows[0]->destination)->toBe('+4471') + ->and($rows[1]->status)->toBe('blocked') + ->and($rows[1]->block_reason)->toBe('paused') + ->and($rows[1]->company_uuid)->toBe('company-1'); +}); + +test('a burst of sends to a country the tenant has never texted pauses it', function () { + [$guard, $breaker, $capsule] = sms_guard_ledger_setup(); + + // Not armed yet: no history. Three sends to a new country go through. + foreach (['+97699112201', '+97699112202', '+97699112203'] as $number) { + $guard->reserve(SmsContext::resolve($number, ['purpose' => 'storefront_create_customer'])); + } + expect($breaker->alerts)->toBe([]) + ->and($breaker->pausedUntil('tenant:store:store-1'))->toBeNull(); + + // With history in GB, the same burst to Mongolia trips the breaker on the third send. + sms_guard_ledger_seed_sent($capsule, 'company-1', 'GB', 6); + Illuminate\Support\Facades\Cache::forget('sms-guard:countries:company-1'); + Illuminate\Support\Facades\Cache::forget('sms-guard:sent-count:company-1'); + + $guard->reserve(SmsContext::resolve('+97699112211', ['purpose' => 'storefront_create_customer'])); + $guard->reserve(SmsContext::resolve('+97699112212', ['purpose' => 'storefront_create_customer'])); + $guard->reserve(SmsContext::resolve('+97699112213', ['purpose' => 'storefront_create_customer'])); + + $refused = null; + try { + $guard->reserve(SmsContext::resolve('+97699112214', ['purpose' => 'storefront_create_customer'])); + } catch (SmsBlockedException $e) { + $refused = $e; + } + + // A known country is still fine for the company's other store. + session(['company' => 'company-1', 'storefront_store' => 'store-2']); + $known = $guard->reserve(SmsContext::resolve('+447400100199', ['purpose' => 'storefront_create_customer'])); + + expect($breaker->alerts)->toHaveCount(1) + ->and($breaker->alerts[0]->signal)->toBe(SmsBreaker::SIGNAL_NEW_COUNTRY) + ->and($breaker->alerts[0]->data['country'])->toBe('MN') + ->and($breaker->alerts[0]->data['known_countries'])->toBe(['GB']) + ->and($breaker->alerts[0]->toArray(null)['title'])->toBe('SMS to a country new to the tenant') + ->and($breaker->pausedUntil('tenant:store:store-1')['reason'])->toBe(SmsBreaker::SIGNAL_NEW_COUNTRY) + ->and($refused?->status())->toBe(423) + ->and($known->decision)->toBe('allowed'); +}); + +test('codes that are sent by sms but never verified pause the company', function () { + [$guard, $breaker, $capsule] = sms_guard_ledger_setup(); + $codes = []; + foreach (range(1, 20) as $i) { + $codes[] = [ + 'uuid' => 'code-' . $i, + 'company_uuid' => 'company-1', + 'channel' => 'sms', + 'code' => '123456', + 'for' => 'storefront_create_customer', + 'status' => 'active', + 'expires_at' => Carbon::now()->addMinutes(10), + 'consumed_at' => $i <= 1 ? Carbon::now() : null, + 'created_at' => Carbon::now()->subMinutes(5), + 'updated_at' => Carbon::now()->subMinutes(5), + ]; + } + $capsule->getConnection('mysql')->table('verification_codes')->insert($codes); + + $guard->reserve(SmsContext::resolve('+447400100123', ['purpose' => 'storefront_create_customer'])); + + $refused = null; + try { + $guard->reserve(SmsContext::resolve('+447400100124', ['purpose' => 'storefront_create_customer'])); + } catch (SmsBlockedException $e) { + $refused = $e; + } + + expect($breaker->alerts)->toHaveCount(1) + ->and($breaker->alerts[0]->signal)->toBe(SmsBreaker::SIGNAL_LOW_CONVERSION) + ->and($breaker->alerts[0]->data)->toMatchArray(['issued_last_hour' => 20, 'verified_last_hour' => 1]) + ->and($breaker->alerts[0]->toArray(null)['title'])->toBe('SMS codes going unverified') + ->and($breaker->pausedUntil('company:company-1'))->not->toBeNull() + ->and($refused?->status())->toBe(423); + + // Healthy conversion trips nothing. + [$healthyGuard, $healthyBreaker, $healthyCapsule] = sms_guard_ledger_setup(); + $healthyCapsule->getConnection('mysql')->table('verification_codes')->insert(array_map(fn ($code) => array_merge($code, ['consumed_at' => Carbon::now()]), $codes)); + $healthyGuard->reserve(SmsContext::resolve('+447400100123', ['purpose' => 'storefront_create_customer'])); + + expect($healthyBreaker->alerts)->toBe([]); +}); diff --git a/tests/Unit/Support/Sms/SmsLedgerTest.php b/tests/Unit/Support/Sms/SmsLedgerTest.php new file mode 100644 index 00000000..192d17c7 --- /dev/null +++ b/tests/Unit/Support/Sms/SmsLedgerTest.php @@ -0,0 +1,122 @@ + 'GB', 'destination_type' => 'MOBILE', 'meta' => ['decision' => 'allowed']]); + SmsLedger::update($uuid, ['status' => SmsSend::STATUS_SENT, 'provider' => 'twilio', 'provider_message_id' => 'SM1']); + SmsLedger::update(null, ['status' => 'ignored']); + + $row = SmsSend::where('uuid', $uuid)->first(); + + expect($uuid)->toBeString() + ->and($row->company_uuid)->toBe('company-1') + ->and($row->tenant_type)->toBe('store') + ->and($row->tenant_uuid)->toBe('store-1') + ->and($row->purpose)->toBe('storefront_create_customer') + ->and($row->source)->toBe('storefront') + ->and($row->destination)->toBe('+447400100123') + ->and($row->destination_hash)->toBe(hash('sha256', '+447400100123')) + ->and($row->destination_country)->toBe('GB') + ->and($row->status)->toBe('sent') + ->and($row->provider)->toBe('twilio') + ->and($row->provider_message_id)->toBe('SM1') + ->and($row->ip_address)->toBe('203.0.113.9') + ->and($row->user_agent)->toBe('StorefrontApp/3.1') + ->and($row->meta)->toBe(['decision' => 'allowed']) + ->and(SmsSend::count())->toBe(1); +}); + +test('sms ledger answers country and volume questions from sent rows only', function () { + SmsGuardFixture::database(); + + foreach ([['GB', 'sent'], ['GB', 'sent'], ['MN', 'sent'], ['US', 'blocked'], ['FR', 'failed']] as [$country, $status]) { + SmsLedger::record(sms_ledger_context(), $status, ['destination_country' => strtolower($country)]); + } + SmsLedger::record(sms_ledger_context('+15550001111', 'company-2'), 'sent', ['destination_country' => 'US']); + + expect(SmsLedger::countriesFor('company-1'))->toEqualCanonicalizing(['GB', 'MN']) + ->and(SmsLedger::sentCountFor('company-1'))->toBe(3) + ->and(SmsLedger::sentCountFor('company-2'))->toBe(1) + ->and(SmsLedger::countriesFor('company-none'))->toBe([]); +}); + +test('sms ledger deletes rows past retention and masks the number on older surviving rows', function () { + SmsGuardFixture::database(); + Carbon::setTestNow(Carbon::parse('2026-10-13 12:00:00', 'UTC')); + + $fresh = SmsLedger::record(sms_ledger_context('+447400100001'), 'sent'); + $old = SmsLedger::record(sms_ledger_context('+447400100002'), 'sent'); + $gone = SmsLedger::record(sms_ledger_context('+447400100003'), 'sent'); + SmsSend::where('uuid', $old)->update(['created_at' => Carbon::now()->subDays(45)]); + SmsSend::where('uuid', $gone)->update(['created_at' => Carbon::now()->subDays(120)]); + + $result = SmsLedger::prune(90, 30); + $again = SmsLedger::prune(90, 30); + + expect($result)->toBe(['masked' => 1, 'deleted' => 1]) + ->and($again)->toBe(['masked' => 0, 'deleted' => 0]) + ->and(SmsSend::where('uuid', $fresh)->value('destination'))->toBe('+447400100001') + ->and(SmsSend::where('uuid', $old)->value('destination'))->toBe('•••0002') + ->and(SmsSend::where('uuid', $old)->value('destination_hash'))->toBe(hash('sha256', '+447400100002')) + ->and(SmsSend::where('uuid', $gone)->exists())->toBeFalse() + ->and(SmsLedger::mask('+1 (415) 555-2671'))->toBe('•••2671') + ->and(SmsLedger::prune(0, 0))->toBe(['masked' => 0, 'deleted' => 0]); +}); + +test('sms ledger does nothing without a database', function () { + bind_test_container([]); + Facade::clearResolvedInstances(); + + expect(SmsLedger::available())->toBeFalse() + ->and(SmsLedger::record(sms_ledger_context(), 'reserved'))->toBeNull() + ->and(SmsLedger::countriesFor('company-1'))->toBe([]) + ->and(SmsLedger::sentCountFor('company-1'))->toBe(0); + + SmsLedger::update('some-uuid', ['status' => 'sent']); + expect(true)->toBeTrue(); +}); + +test('sms ledger stops writing for a minute after a failure and never throws', function () { + $capsule = SmsGuardFixture::database(); + $capsule->getConnection('mysql')->getSchemaBuilder()->drop('sms_sends'); + + expect(SmsLedger::available())->toBeTrue() + ->and(SmsLedger::record(sms_ledger_context(), 'reserved'))->toBeNull() + ->and(Cache::has(SmsLedger::UNAVAILABLE_KEY))->toBeTrue() + ->and(SmsLedger::available())->toBeFalse() + ->and(collect(app('log')->entries)->pluck(1)->all())->toContain('sms.ledger_unavailable'); + + SmsGuardFixture::smsSendsSchema($capsule); + + // Still marked unavailable, so nothing is written even though the table is back. + expect(SmsLedger::record(sms_ledger_context(), 'reserved'))->toBeNull() + ->and(SmsSend::count())->toBe(0); + + Cache::forget(SmsLedger::UNAVAILABLE_KEY); + SmsLedger::update('missing-row', ['status' => 'sent']); + + expect(SmsLedger::record(sms_ledger_context(), 'reserved'))->toBeString() + ->and(SmsSend::count())->toBe(1); +}); From d51a4d4dce64b922491a382476bc9961577a4c07 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 23:14:39 +0800 Subject: [PATCH 48/51] test(auth): drop the authenticateSmsCode tests the method's removal left behind #307 removed the unrouted legacy SMS login methods from AuthController along with their tests in AuthControllerVerificationTest, but the same cases also lived in AuthControllerLoginBootstrapTest. Those five now fail PHP CI on release/v1.6.69 with BadMethodCallException. --- .../Http/AuthControllerLoginBootstrapTest.php | 112 ------------------ 1 file changed, 112 deletions(-) diff --git a/tests/Unit/Http/AuthControllerLoginBootstrapTest.php b/tests/Unit/Http/AuthControllerLoginBootstrapTest.php index 50dcd298..eab92f5b 100644 --- a/tests/Unit/Http/AuthControllerLoginBootstrapTest.php +++ b/tests/Unit/Http/AuthControllerLoginBootstrapTest.php @@ -1261,118 +1261,6 @@ function auth_controller_insert_join_invite(Capsule $capsule, string $companyUui ->and(session('impersonator'))->toBeNull(); }); -// ----------------------------------------------------------------------------- -// authenticateSmsCode — SMS-2FA security contracts (bypass gating + replay). -// These assert the security-critical branches that run before/around the user -// lookup, so they do not depend on the Auth::login guard path. -// ----------------------------------------------------------------------------- - -function auth_controller_sms_request(array $input): Request -{ - return Request::create('/int/v1/auth/authenticate-sms', 'POST', $input); -} - -test('authenticate sms code rejects an invalid verification code', function () { - auth_controller_login_bootstrap_database(); - - $response = (new AuthController())->authenticateSmsCode(auth_controller_sms_request([ - 'phone' => '+15555550123', - 'code' => '000000', - ])); - - expect($response->getStatusCode())->toBe(400) - ->and($response->getData(true))->toBe(['errors' => ['Invalid verification code']]); -}); - -test('authenticate sms code refuses the bypass code in production', function () { - auth_controller_login_bootstrap_database(); - config([ - 'app.env' => 'production', - 'fleetbase.sms_auth_bypass_code' => 'BYPASS-PROD', - ]); - - // No stored OTP; the only candidate is the bypass code, which must NOT be honored - // in a production environment. - $response = (new AuthController())->authenticateSmsCode(auth_controller_sms_request([ - 'phone' => '+15555550123', - 'code' => 'BYPASS-PROD', - ])); - - expect($response->getStatusCode())->toBe(400) - ->and($response->getData(true))->toBe(['errors' => ['Invalid verification code']]); -}); - -test('authenticate sms code accepts the bypass code outside production', function () { - auth_controller_login_bootstrap_database(); - config(['fleetbase.sms_auth_bypass_code' => 'BYPASS-DEV']); - - // Non-production env (default 'testing') with no user for the phone: the bypass code - // passes validation and falls through to the failing user lookup, proving it was - // accepted only because the environment is not production. - $response = (new AuthController())->authenticateSmsCode(auth_controller_sms_request([ - 'phone' => '+15555559999', - 'code' => 'BYPASS-DEV', - ])); - - expect($response->getStatusCode())->toBe(401) - ->and($response->getData(true))->toBe('Authentication failed'); -}); - -test('authenticate sms code consumes the stored otp to prevent replay', function () { - auth_controller_login_bootstrap_database(); - - $phone = '+15555559999'; - $key = SupportStr::slug($phone . '_verify_code', '_'); - app('redis')->set($key, '654321'); - - // First use: valid OTP but no user for this phone -> 401, and the OTP is deleted. - $first = (new AuthController())->authenticateSmsCode(auth_controller_sms_request([ - 'phone' => $phone, - 'code' => '654321', - ])); - - expect($first->getStatusCode())->toBe(401) - ->and(app('redis')->get($key))->toBeNull(); - - // Replaying the same code now fails because the stored OTP was consumed. - $replay = (new AuthController())->authenticateSmsCode(auth_controller_sms_request([ - 'phone' => $phone, - 'code' => '654321', - ])); - - expect($replay->getStatusCode())->toBe(400) - ->and($replay->getData(true))->toBe(['errors' => ['Invalid verification code']]); -}); - -test('authenticate sms code authenticates a matching user and issues a token on a valid otp', function () { - $capsule = auth_controller_login_bootstrap_database(); - auth_controller_login_insert_user($capsule); // phone +15555550123 - - $authGuard = new AuthControllerLoginBootstrapAuthGuardFake(); - app()->instance('auth', $authGuard); - Facade::clearResolvedInstance('auth'); - - $phone = '+15555550123'; - $key = SupportStr::slug($phone . '_verify_code', '_'); - app('redis')->set($key, '123456'); - - $response = (new AuthController())->authenticateSmsCode(auth_controller_sms_request([ - 'phone' => $phone, - 'code' => '123456', - ])); - - $payload = $response->getData(true); - - expect($response->getStatusCode())->toBe(200) - ->and($payload['token'])->toBeString() - ->and($payload['token'])->not->toBeEmpty() - ->and($payload['user']['uuid'])->toBe('11111111-1111-4111-8111-111111111111') - // The user was logged in through the guard, the OTP consumed, and a token issued. - ->and($authGuard->loggedIn)->toHaveCount(1) - ->and(app('redis')->get($key))->toBeNull() - ->and($capsule->getConnection('mysql')->table('personal_access_tokens')->count())->toBe(1); -}); - test('login rejects driver and contact identities from the console', function (string $type) { $capsule = auth_controller_login_bootstrap_database(); auth_controller_login_insert_user($capsule, [ From 20b381d9e2b2bdcbce4599c7dc2f3b7caa61c568 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 23:28:22 +0800 Subject: [PATCH 49/51] ci: keep the coverage report when the gate fails The clover artifact is what shows which lines are short, and it was only uploaded after a passing gate. --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb6ecd72..e823a528 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,6 +64,8 @@ jobs: run: composer coverage:baseline - name: Upload Coverage Baseline + # Keep the report when the gate fails: that is when the line detail is needed. + if: ${{ !cancelled() }} uses: actions/upload-artifact@v4 with: name: coverage-baseline From 4aa48b862e7c527e3492cafced82cb482df91eda Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 23:42:51 +0800 Subject: [PATCH 50/51] test: cover the SMS guard and comment broadcast branches the release gate flagged The release branch's coverage gate was never evaluated while the orphaned authenticateSmsCode tests failed first. With those gone it reports 73 statements short across the SMS guard, the comment broadcast, the exception handler and the notification registry. This covers them: - breaker: expired pauses, a second hour at budget, the platform threshold, two signals tripping on one send, sends with no company, a missing platform counter, scope lookups and conversion reads when a table is gone, and alert delivery to administrators and plain addresses, including a failed delivery - guard: a pause recorded in monitor mode, the configured cache store and its fallback; alert: every signal's wording, via() and toMail() - context: a network tenant, an unresolvable request, an unknown path - ledger and policy: a cache that is itself unreachable, tables that cannot be queried, purposes without a name or rules; validator: a number the parser rejects outright - comment broadcasts: a bare, preloaded, unknown or broken subject - handler: a refused SMS keeps its status and Retry-After (the test response factory now passes headers through, as the real one does) - verification codes: legacy rows on a table without the tracing columns and a code issued with no request to read the client address from - notification registry: an unknown definition and a registered notifiable that is not a model Two catch blocks the harness cannot reach (app() and session() never fail in it) are marked for coverage with the reason. --- src/Support/Sms/SmsContext.php | 4 + src/Support/Sms/SmsGuard.php | 5 +- tests/Fixtures/Support/SmsGuardFixture.php | 17 ++ tests/Pest.php | 4 +- .../Unit/Exceptions/ExceptionHandlerTest.php | 15 ++ tests/Unit/Http/CommentControllerTest.php | 28 +++ .../Models/VerificationCodeTracingTest.php | 41 +++++ .../Unit/Notifications/SmsGuardAlertTest.php | 48 +++++ .../Unit/Support/NotificationRegistryTest.php | 29 +++ tests/Unit/Support/Sms/PhoneValidatorTest.php | 5 + tests/Unit/Support/Sms/SmsGuardLedgerTest.php | 93 ++++++++++ tests/Unit/Support/Sms/SmsGuardTest.php | 165 ++++++++++++++++++ tests/Unit/Support/Sms/SmsLedgerTest.php | 18 ++ tests/Unit/Support/Sms/SmsPolicyTest.php | 24 +++ 14 files changed, 493 insertions(+), 3 deletions(-) create mode 100644 tests/Unit/Notifications/SmsGuardAlertTest.php diff --git a/src/Support/Sms/SmsContext.php b/src/Support/Sms/SmsContext.php index 1b472110..56eeb776 100644 --- a/src/Support/Sms/SmsContext.php +++ b/src/Support/Sms/SmsContext.php @@ -179,9 +179,13 @@ private static function sessionValues(): array 'storefront_store' => $session->get('storefront_store'), 'storefront_network' => $session->get('storefront_network'), ]; + // @codeCoverageIgnoreStart } catch (\Throwable $e) { + // A console or queued send has no session store. The test harness's session() + // is a static array that cannot fail, so this branch only runs in production. return []; } + // @codeCoverageIgnoreEnd } private static function currentRequest(): ?Request diff --git a/src/Support/Sms/SmsGuard.php b/src/Support/Sms/SmsGuard.php index b783261f..8714d64b 100644 --- a/src/Support/Sms/SmsGuard.php +++ b/src/Support/Sms/SmsGuard.php @@ -48,9 +48,12 @@ public static function make(): static if (app()->bound(static::class)) { return app(static::class); } + // @codeCoverageIgnoreStart } catch (\Throwable $e) { - // no container: fall through to a fresh guard + // Only a host without a container gets here, and app() in the test harness + // always answers; fall through to a fresh guard. } + // @codeCoverageIgnoreEnd return new static(); } diff --git a/tests/Fixtures/Support/SmsGuardFixture.php b/tests/Fixtures/Support/SmsGuardFixture.php index 4f61a2a2..886ba74d 100644 --- a/tests/Fixtures/Support/SmsGuardFixture.php +++ b/tests/Fixtures/Support/SmsGuardFixture.php @@ -124,6 +124,23 @@ public static function companiesSchema(Capsule $capsule, array $rows = [], strin } } + public static function usersSchema(Capsule $capsule, array $rows = [], string $connection = 'mysql'): void + { + $capsule->getConnection($connection)->getSchemaBuilder()->create('users', function ($table) { + $table->string('uuid')->primary(); + $table->string('company_uuid')->nullable(); + $table->string('type')->nullable(); + $table->string('name')->nullable(); + $table->string('email')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + if ($rows) { + $capsule->getConnection($connection)->table('users')->insert($rows); + } + } + public static function settingsSchema(Capsule $capsule, string $connection = 'mysql'): void { $capsule->getConnection($connection)->getSchemaBuilder()->create('settings', function ($table) { diff --git a/tests/Pest.php b/tests/Pest.php index fb334573..6d8a0f93 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -647,9 +647,9 @@ public function authorizationError(?array $data = []): JsonResponse ); } - public function json(mixed $data, int $statusCode = 200): JsonResponse + public function json(mixed $data, int $statusCode = 200, array $headers = []): JsonResponse { - return new JsonResponse($data, $statusCode); + return new JsonResponse($data, $statusCode, $headers); } public function download(string $file, ?string $name = null, array $headers = []): Symfony\Component\HttpFoundation\BinaryFileResponse diff --git a/tests/Unit/Exceptions/ExceptionHandlerTest.php b/tests/Unit/Exceptions/ExceptionHandlerTest.php index 08c71d30..a73874c9 100644 --- a/tests/Unit/Exceptions/ExceptionHandlerTest.php +++ b/tests/Unit/Exceptions/ExceptionHandlerTest.php @@ -59,6 +59,7 @@ function logger() namespace { use Fleetbase\Exceptions\FleetbaseRequestValidationException; use Fleetbase\Exceptions\Handler; + use Fleetbase\Exceptions\SmsBlockedException; use Fleetbase\Models\User; use Illuminate\Auth\AuthenticationException; use Illuminate\Database\Eloquent\ModelNotFoundException; @@ -216,6 +217,20 @@ function exception_handler_subject(): Handler ]); }); + it('answers a refused sms with its own status, retry-after and the standard error envelope', function () { + $handler = exception_handler_subject(); + + $limited = $handler->render(Request::create('/storefront/v1/customers/request-creation-code', 'POST'), SmsBlockedException::limited('cooldown', 30)); + $invalid = $handler->render(Request::create('/v1/drivers/login-with-sms', 'POST'), SmsBlockedException::invalidDestination('invalid_destination')); + + expect($limited->getStatusCode())->toBe(429) + ->and($limited->headers->get('Retry-After'))->toBe('30') + ->and($limited->getData(true))->toBe(['errors' => ['Unable to send a verification code right now. Try again in a minute.']]) + ->and($invalid->getStatusCode())->toBe(422) + ->and($invalid->headers->has('Retry-After'))->toBeFalse() + ->and($invalid->getData(true))->toBe(['errors' => ['The phone number provided is not valid.']]); + }); + it('delegates unknown exceptions to the framework renderer', function () { $handler = exception_handler_subject(); $exception = new RuntimeException('Unexpected failure'); diff --git a/tests/Unit/Http/CommentControllerTest.php b/tests/Unit/Http/CommentControllerTest.php index 0d4b8d78..05adae52 100644 --- a/tests/Unit/Http/CommentControllerTest.php +++ b/tests/Unit/Http/CommentControllerTest.php @@ -655,3 +655,31 @@ public function dispatch($event, $payload = [], $halt = false) ->and($dispatched[0]->comment->is($comment))->toBeTrue() ->and($stillCreated->exists)->toBeTrue(); }); + +test('comment created broadcasts to the subject only when the subject can be resolved', function () { + comment_visibility_database(); + + $channels = fn (Comment $comment) => array_map(fn ($channel) => (string) $channel, (new Fleetbase\Events\CommentCreated($comment))->broadcastOn()); + $comment = function (array $attributes): Comment { + $comment = new Comment(); + $comment->setRawAttributes(array_merge(['uuid' => 'comment-x', 'company_uuid' => 'company-1'], $attributes), true); + $comment->meta = ['visibility' => 'customer']; + + return $comment; + }; + + $bare = $comment(['subject_type' => null, 'subject_uuid' => null]); + $loaded = $comment(['subject_type' => Fleetbase\Tests\Fixtures\Models\Order::class, 'subject_uuid' => 'order-uuid-9']); + $loaded->setRelation('subject', new Fleetbase\Tests\Fixtures\Models\Order(['uuid' => 'order-uuid-9', 'public_id' => 'order_9'])); + $missing = $comment(['subject_type' => 'Fleetbase\\Missing\\Model', 'subject_uuid' => 'order-uuid-1']); + $broken = $comment(['subject_type' => stdClass::class, 'subject_uuid' => 'order-uuid-1']); + + expect($channels($bare))->toBe(['company.company-1']) + ->and($channels($loaded))->toBe(['company.company-1', 'order.order_9', 'order.order-uuid-9']) + ->and($channels($missing))->toBe(['company.company-1']) + ->and($channels($broken))->toBe(['company.company-1']) + ->and(Comment::isVisibilityAwareSubjectType(null))->toBeFalse() + ->and(Comment::isVisibilityAwareSubjectType('order'))->toBeTrue() + ->and(Comment::isVisibilityAwareSubjectType(Fleetbase\Tests\Fixtures\Models\Order::class))->toBeTrue() + ->and(Comment::isVisibilityAwareSubjectType('contact'))->toBeFalse(); +}); diff --git a/tests/Unit/Models/VerificationCodeTracingTest.php b/tests/Unit/Models/VerificationCodeTracingTest.php index 45c32bd7..a3d9b1bf 100644 --- a/tests/Unit/Models/VerificationCodeTracingTest.php +++ b/tests/Unit/Models/VerificationCodeTracingTest.php @@ -11,6 +11,7 @@ use Illuminate\Events\Dispatcher; use Illuminate\Http\Request; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Facade; class VerificationCodeTracingTwilioFake @@ -216,3 +217,43 @@ function verification_code_tracing_subject(array $attributes = []): User ->and($sms->meta)->toBe([]) ->and(SmsSend::count())->toBe(0); }); + +it('finds legacy codes by their meta identity on a table without the tracing columns', function () { + verification_code_tracing_database(false); + $db = EloquentModel::getConnectionResolver()->connection('mysql'); + $db->getSchemaBuilder()->drop('verification_codes'); + $db->getSchemaBuilder()->create('verification_codes', function ($table) { + $table->string('uuid')->primary(); + $table->string('subject_uuid')->nullable(); + $table->string('subject_type')->nullable(); + $table->string('code')->nullable(); + $table->string('for')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->text('meta')->nullable(); + $table->string('status')->nullable(); + $table->timestamp('deleted_at')->nullable(); + $table->timestamps(); + }); + Cache::forget('verification_codes:tracing_columns:mysql'); + $db->table('verification_codes')->insert([ + ['uuid' => 'legacy', 'code' => '111111', 'for' => 'storefront_login', 'subject_uuid' => 'user-1', 'status' => 'active', 'meta' => json_encode(['identity' => '+447400100001']), 'expires_at' => Carbon::now()->addHour(), 'created_at' => Carbon::now(), 'updated_at' => Carbon::now()], + ]); + + expect(VerificationCode::supportsTracingColumns())->toBeFalse() + ->and(VerificationCode::findActiveFor('storefront_login', '+447400100001')?->uuid)->toBe('legacy') + ->and(VerificationCode::findActiveFor('storefront_login', '+447400100002'))->toBeNull(); +}); + +it('records no client address when there is no request to read it from', function () { + verification_code_tracing_database(false); + app()->bind('request', fn () => throw new RuntimeException('no request bound')); + + $code = VerificationCode::generateEmailVerificationFor(verification_code_tracing_subject(), 'storefront_create_customer', ['meta' => ['identity' => 'ada@example.test']]); + + app()->instance('request', Request::create('/')); + + expect($code->channel)->toBe('email') + ->and($code->identity)->toBe('ada@example.test') + ->and($code->ip_address)->toBeNull() + ->and($code->user_agent)->toBeNull(); +}); diff --git a/tests/Unit/Notifications/SmsGuardAlertTest.php b/tests/Unit/Notifications/SmsGuardAlertTest.php new file mode 100644 index 00000000..47fa417d --- /dev/null +++ b/tests/Unit/Notifications/SmsGuardAlertTest.php @@ -0,0 +1,48 @@ + 60, 'hours_at_budget_today' => 2, 'paused_minutes' => null, 'mode' => 'enforce']); + $platform = SmsGuardAlert::for('platform_budget_threshold', $context, ['percent' => 80, 'sends_this_hour' => 1200, 'budget' => 1500, 'scope' => 'platform']); + $unknown = SmsGuardAlert::for('something_new', $context); + + expect($hourly->title)->toBe('tenant hit its hourly SMS budget twice') + ->and($hourly->lines[0])->toBe('The tenant reached its hourly budget of 60 messages in 2 separate hours today.') + ->and($hourly->lines)->toContain('SMS is paused for this scope until an administrator unpauses it.') + ->and($platform->title)->toBe('platform at 80% of its hourly SMS budget') + ->and($platform->lines[0])->toBe('All tenants together have sent 1200 of 1500 messages this hour.') + ->and($platform->lines[1])->toBe('Scope platform (company company-1), purpose storefront_create_customer, source storefront.') + ->and($unknown->title)->toBe('SMS guard signal: something_new') + ->and($unknown->lines[0])->toBe('The SMS guard raised something_new.') + ->and($unknown->data['context']['destination'])->toBe('+447400100123'); +}); + +test('sms guard alert goes in app and by mail to administrators, and by mail only to plain addresses', function () { + bind_test_container(['app.name' => 'Fleetbase']); + $alert = SmsGuardAlert::for('company_daily_budget', sms_guard_alert_context(), ['sends_today' => 200, 'budget' => 200, 'paused_minutes' => 60, 'mode' => 'enforce']); + $mail = $alert->toMail(null); + + expect($alert->via(new User()))->toBe(['mail', 'database']) + ->and($alert->via(new stdClass()))->toBe(['mail']) + ->and($mail)->toBeInstanceOf(MailMessage::class) + ->and($mail->subject)->toBe('Fleetbase SMS guard: tenant hit its daily SMS budget') + ->and($mail->level)->toBe('error') + ->and($mail->introLines)->toBe($alert->lines) + ->and($alert->toArray(null)['signal'])->toBe('company_daily_budget'); +}); diff --git a/tests/Unit/Support/NotificationRegistryTest.php b/tests/Unit/Support/NotificationRegistryTest.php index f83d9c80..d7b90a15 100644 --- a/tests/Unit/Support/NotificationRegistryTest.php +++ b/tests/Unit/Support/NotificationRegistryTest.php @@ -613,6 +613,35 @@ function notification_registry_dispatch_database(): Capsule expect(NotificationRegistryDispatchTarget::$sent)->toBe([]); }); +test('notification registry resolves nothing for an unknown definition or a registered notifiable that is not a model', function () { + notification_registry_dispatch_database(); + NotificationRegistry::registerNotifiable(stdClass::class); + + Fleetbase\Models\Setting::query()->create([ + 'key' => 'company.company-1.notification_settings', + 'value' => [ + 'notificationRegistryDispatchNotification__manualNotice' => [ + 'notifiables' => [ + [ + 'definition' => stdClass::class, + 'primaryKey' => 'uuid', + 'key' => 'target-1', + ], + ], + ], + ], + ]); + + $subject = new NotificationRegistryDispatchSubject(); + $subject->uuid = 'subject-7'; + $subject->company_uuid = 'company-1'; + + NotificationRegistry::notifyUsingDefinitionName(NotificationRegistryDispatchNotification::class, 'Manual Notice', $subject, 'ignored'); + + expect(NotificationRegistryDispatchTarget::$sent)->toBe([]) + ->and(NotificationRegistry::findNotificationRegistrationByDefinition('Nope\\Unknown\\Notification'))->toBeNull(); +}); + function notification_registry_settings_with(array $notifiables): void { Fleetbase\Models\Setting::query()->create([ diff --git a/tests/Unit/Support/Sms/PhoneValidatorTest.php b/tests/Unit/Support/Sms/PhoneValidatorTest.php index fcdc85d3..74da96b2 100644 --- a/tests/Unit/Support/Sms/PhoneValidatorTest.php +++ b/tests/Unit/Support/Sms/PhoneValidatorTest.php @@ -40,6 +40,11 @@ ->and($land)->toMatchArray(['valid' => false, 'type' => 'FIXED_LINE', 'reason' => PhoneValidator::REASON_TYPE_NOT_ALLOWED]); }); +test('phone validator treats a number the parser rejects outright as invalid', function () { + expect(PhoneValidator::validate('+12'))->toMatchArray(['valid' => false, 'reason' => PhoneValidator::REASON_INVALID]) + ->and(PhoneValidator::validate('+12', ['enabled' => false]))->toMatchArray(['valid' => true, 'e164' => '+12', 'reason' => null]); +}); + test('phone validator applies blocked prefixes, blocked countries and a company allowlist', function () { $prefix = PhoneValidator::validate('+447400100123', ['blocked_prefixes' => '+800, +4474']); $country = PhoneValidator::validate('+447400100123', ['blocked_countries' => 'gb,fr']); diff --git a/tests/Unit/Support/Sms/SmsGuardLedgerTest.php b/tests/Unit/Support/Sms/SmsGuardLedgerTest.php index 3b25f9fd..57f00786 100644 --- a/tests/Unit/Support/Sms/SmsGuardLedgerTest.php +++ b/tests/Unit/Support/Sms/SmsGuardLedgerTest.php @@ -2,6 +2,7 @@ use Fleetbase\Exceptions\SmsBlockedException; use Fleetbase\Models\SmsSend; +use Fleetbase\Models\VerificationCode; use Fleetbase\Notifications\SmsGuardAlert; use Fleetbase\Support\Sms\SmsBreaker; use Fleetbase\Support\Sms\SmsContext; @@ -10,10 +11,13 @@ use Fleetbase\Tests\Fixtures\Support\SmsGuardFixture; use Illuminate\Cache\ArrayStore; use Illuminate\Cache\Repository as CacheRepository; +use Illuminate\Contracts\Notifications\Dispatcher as NotificationDispatcher; use Illuminate\Database\Capsule\Manager as Capsule; use Illuminate\Database\Eloquent\Model as EloquentModel; use Illuminate\Events\Dispatcher; use Illuminate\Http\Request; +use Illuminate\Notifications\AnonymousNotifiable; +use Illuminate\Notifications\ChannelManager; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Facade; use Illuminate\Support\Str; @@ -29,6 +33,38 @@ protected function dispatchAlert(SmsGuardAlert $alert): void } } +/** + * What the Notification facade and an anonymous (mail only) notifiable hand the alert to. + */ +class SmsGuardLedgerTestChannelManager +{ + /** @var array */ + public array $sent = []; + + public function __construct(public bool $broken = false) + { + } + + public function sendNow($notifiables, $notification, ?array $channels = null): void + { + if ($this->broken) { + throw new RuntimeException('mail server down'); + } + + $this->sent[] = [$notifiables, $notification]; + } +} + +function sms_guard_ledger_channel_manager(bool $broken = false): SmsGuardLedgerTestChannelManager +{ + $manager = new SmsGuardLedgerTestChannelManager($broken); + app()->instance(ChannelManager::class, $manager); + app()->instance(NotificationDispatcher::class, $manager); + Facade::clearResolvedInstance(ChannelManager::class); + + return $manager; +} + /** * The guard with a real ledger and verification code table behind it. * @@ -258,3 +294,60 @@ function sms_guard_ledger_seed_sent(Capsule $capsule, string $company, string $c expect($healthyBreaker->alerts)->toBe([]); }); + +test('sms breaker alerts every administrator in app and by mail, and plain addresses by mail only', function () { + [, , $capsule] = sms_guard_ledger_setup([ + 'alert_emails' => 'ops@example.test, root@example.test', + 'limits' => ['company' => ['per_minute' => 100, 'per_hour' => 100, 'per_day' => 1]], + ]); + SmsGuardFixture::usersSchema($capsule, [ + ['uuid' => 'admin-1', 'type' => 'admin', 'name' => 'Root', 'email' => 'Root@example.test'], + ['uuid' => 'user-1', 'type' => 'user', 'name' => 'Member', 'email' => 'member@example.test'], + ]); + $manager = sms_guard_ledger_channel_manager(); + + // The real breaker, so the alert is dispatched rather than collected. + $guard = new SmsGuard(app('cache')); + $reservation = $guard->reserve(SmsContext::resolve('+447400100123', ['purpose' => 'storefront_create_customer'])); + + expect($reservation->decision)->toBe('allowed') + ->and($manager->sent)->toHaveCount(2) + ->and($manager->sent[0][0]->pluck('uuid')->all())->toBe(['admin-1']) + ->and($manager->sent[0][1])->toBeInstanceOf(SmsGuardAlert::class) + ->and($manager->sent[0][1]->signal)->toBe(SmsBreaker::SIGNAL_COMPANY_DAILY_BUDGET) + ->and($manager->sent[1][0])->toBeInstanceOf(AnonymousNotifiable::class) + ->and($manager->sent[1][0]->routeNotificationFor('mail'))->toBe(['ops@example.test']) + ->and($manager->sent[1][1])->toBe($manager->sent[0][1]) + ->and(collect(app('log')->entries)->pluck(1)->all())->toContain('sms.alert') + ->and($guard->breaker()->pausedUntil('company:company-1'))->not->toBeNull(); +}); + +test('sms breaker keeps sending when an alert cannot be delivered or the conversion data cannot be read', function () { + sms_guard_ledger_setup([ + 'alert_emails' => 'ops@example.test', + 'limits' => ['company' => ['per_minute' => 100, 'per_hour' => 100, 'per_day' => 1]], + ]); + sms_guard_ledger_channel_manager(broken: true); + + // No users table either: the administrator lookup fails quietly and the addresses from + // the policy are still tried. + $guard = new SmsGuard(app('cache')); + $reservation = $guard->reserve(SmsContext::resolve('+447400100123', ['purpose' => 'storefront_create_customer'])); + + expect($reservation->decision)->toBe('allowed') + ->and($guard->breaker()->pausedUntil('company:company-1'))->not->toBeNull() + ->and(collect(app('log')->entries)->where(1, 'sms.alert_not_sent')->first()[2]['error'])->toBe('mail server down'); + + // The conversion signal needs the verification code table; losing it mid-flight is not an error. + [$guard, $breaker, $capsule] = sms_guard_ledger_setup(); + expect(VerificationCode::supportsTracingColumns())->toBeTrue(); + $capsule->getConnection('mysql')->getSchemaBuilder()->drop('verification_codes'); + + $sent = $guard->reserve(SmsContext::resolve('+447400100124', ['purpose' => 'storefront_create_customer'])); + + expect($sent->decision)->toBe('allowed') + ->and($breaker->alerts)->toBe([]) + // Likewise an operator's scope lookup when the companies table cannot be queried. + ->and(SmsBreaker::resolveScope('company-1'))->toBeNull() + ->and(SmsBreaker::resolveScope('company:company-1'))->toBe('company:company-1'); +}); diff --git a/tests/Unit/Support/Sms/SmsGuardTest.php b/tests/Unit/Support/Sms/SmsGuardTest.php index 31c6f0b7..9fc53d72 100644 --- a/tests/Unit/Support/Sms/SmsGuardTest.php +++ b/tests/Unit/Support/Sms/SmsGuardTest.php @@ -32,6 +32,25 @@ public function add($key, $value, $ttl = null) } } +/** + * Stands in for Laravel's CacheManager: only the default store exists. + */ +class SmsGuardTestCacheManager +{ + public function __construct(private CacheRepository $repository) + { + } + + public function store(?string $name = null): CacheRepository + { + if ($name !== null) { + throw new RuntimeException('Cache store [' . $name . '] is not defined.'); + } + + return $this->repository; + } +} + /** * @return array{0: SmsGuard, 1: SmsGuardTestBreaker, 2: CacheRepository} */ @@ -356,3 +375,149 @@ function sms_guard_refusal(SmsGuard $guard, SmsContext $context): SmsBlockedExce expect(SmsGuard::make())->not->toBe($guard); }); + +test('sms context takes a network tenant from the session and survives a request that cannot be resolved', function () { + sms_guard_test_setup(); + session(['company' => 'company-1', 'storefront_store' => null, 'storefront_network' => 'network-1']); + $network = sms_guard_context('+447400100123'); + + app()->bind('request', fn () => throw new RuntimeException('no request bound')); + $blind = sms_guard_context('+447400100123'); + app()->instance('request', Request::create('/')); + + expect($network->tenantType)->toBe('network') + ->and($network->tenantUuid)->toBe('network-1') + ->and($network->tenantScope())->toBe('tenant:network:network-1') + ->and($blind->ip)->toBeNull() + ->and($blind->userAgent)->toBeNull() + ->and($blind->source)->toBe(SmsContext::SOURCE_SYSTEM) + ->and(SmsContext::detectSource(Request::create('/health')))->toBe(SmsContext::SOURCE_SYSTEM); +}); + +test('sms breaker lets an expired pause lapse and reads a missing platform counter as absent', function () { + [$guard, $breaker, $cache] = sms_guard_test_setup([ + 'limits' => ['platform' => ['public' => ['per_minute' => null, 'per_hour' => null, 'per_day' => 500]]], + 'breaker' => ['alert_percentages' => [50]], + ]); + $cache->forever(SmsGuard::CACHE_PREFIX . 'pause:company:company-1', ['until' => time() - 5, 'reason' => 'old', 'auto' => true, 'company_uuid' => 'company-1', 'at' => time() - 600]); + + $reservation = $guard->reserve(sms_guard_context('+447400100123')); + + expect($breaker->pausedUntil('company:company-1'))->toBeNull() + ->and($reservation->decision)->toBe(SmsReservation::DECISION_ALLOWED) + ->and($breaker->alerts)->toBe([]) + ->and(SmsBreaker::companyUuidFromScope('company:company-1'))->toBe('company-1') + ->and(SmsBreaker::companyUuidFromScope('platform'))->toBeNull(); +}); + +test('sms breaker pauses a tenant that hits its hourly budget in a second hour of the day', function () { + [$guard, $breaker, $cache] = sms_guard_test_setup([ + 'limits' => ['destination' => ['cooldown_seconds' => 0, 'per_hour' => null, 'per_day' => null], 'company' => ['per_minute' => 100, 'per_hour' => 2, 'per_day' => 100]], + 'breaker' => ['alert_percentages' => []], + ]); + // An earlier hour today already reached the budget. + $cache->put(SmsGuard::CACHE_PREFIX . 'hour-trips:company:company-1', 1, 86400); + + $guard->reserve(sms_guard_context('+447400100001')); + $second = $guard->reserve(sms_guard_context('+447400100002')); + $third = sms_guard_refusal($guard, sms_guard_context('+447400100003')); + $pause = $breaker->pausedUntil('company:company-1'); + + expect($second->decision)->toBe(SmsReservation::DECISION_ALLOWED) + ->and($pause['reason'])->toBe(SmsBreaker::SIGNAL_COMPANY_HOURLY_REPEAT) + ->and($third->status())->toBe(423) + ->and($breaker->alerts)->toHaveCount(1) + ->and($breaker->alerts[0]->signal)->toBe(SmsBreaker::SIGNAL_COMPANY_HOURLY_REPEAT) + ->and($breaker->alerts[0]->data)->toMatchArray(['hours_at_budget_today' => 2, 'budget' => 2]) + ->and($breaker->alerts[0]->toArray(null)['title'])->toBe('tenant hit its hourly SMS budget twice'); +}); + +test('sms breaker alerts when the platform bucket nears its hourly budget and ignores a bucket without one', function () { + [$guard, $breaker] = sms_guard_test_setup([ + 'limits' => ['destination' => ['cooldown_seconds' => 0, 'per_hour' => null, 'per_day' => null], 'platform' => ['public' => ['per_minute' => 500, 'per_hour' => 4, 'per_day' => 500]]], + 'breaker' => ['alert_percentages' => [50]], + ]); + + $guard->reserve(sms_guard_context('+447400100001')); + $guard->reserve(sms_guard_context('+447400100002')); + $guard->reserve(sms_guard_context('+447400100003')); + + expect($breaker->alerts)->toHaveCount(1) + ->and($breaker->alerts[0]->signal)->toBe(SmsBreaker::SIGNAL_PLATFORM_THRESHOLD) + ->and($breaker->alerts[0]->data)->toMatchArray(['percent' => 50, 'sends_this_hour' => 2, 'budget' => 4, 'scope' => SmsBreaker::SCOPE_PLATFORM]) + ->and($breaker->alerts[0]->toArray(null)['title'])->toBe('platform at 50% of its hourly SMS budget') + ->and($breaker->pausedUntil(SmsBreaker::SCOPE_PLATFORM))->toBeNull(); + + [$unbounded, $quiet] = sms_guard_test_setup([ + 'limits' => ['destination' => ['cooldown_seconds' => 0, 'per_hour' => null, 'per_day' => null], 'platform' => ['public' => ['per_minute' => null, 'per_hour' => null, 'per_day' => null]]], + 'breaker' => ['alert_percentages' => [50]], + ]); + $unbounded->reserve(sms_guard_context('+447400100001')); + + expect($quiet->alerts)->toBe([]); +}); + +test('sms breaker does not pause a scope twice when two signals trip on the same send', function () { + [$guard, $breaker] = sms_guard_test_setup([ + 'limits' => ['destination' => ['cooldown_seconds' => 0, 'per_hour' => null, 'per_day' => null], 'company' => ['per_minute' => 100, 'per_hour' => 100, 'per_day' => 2]], + 'breaker' => ['distinct_destinations_per_10m' => 2, 'alert_percentages' => []], + ], '/v1/drivers/login-with-sms'); + // No store or network, so the burst signal lands on the company scope like the budget does. + session(['company' => 'company-1', 'storefront_store' => null, 'storefront_network' => null]); + + $guard->reserve(sms_guard_context('+447400100001')); + $guard->reserve(sms_guard_context('+447400100002')); + $pause = $breaker->pausedUntil('company:company-1'); + + expect($pause['reason'])->toBe(SmsBreaker::SIGNAL_COMPANY_DAILY_BUDGET) + ->and($breaker->alerts)->toHaveCount(1) + ->and($breaker->alerts[0]->signal)->toBe(SmsBreaker::SIGNAL_COMPANY_DAILY_BUDGET); +}); + +test('sms breaker skips the tenant signals for a send with no company behind it', function () { + [$guard, $breaker] = sms_guard_test_setup([], '/v1/drivers/login-with-sms'); + session()->flush(); + + $context = sms_guard_context('+447400100123'); + $reservation = $guard->reserve($context); + + expect($context->companyUuid)->toBeNull() + ->and($context->tenantScope())->toBeNull() + ->and($reservation->decision)->toBe(SmsReservation::DECISION_ALLOWED) + ->and($breaker->alerts)->toBe([]) + ->and($guard->limiter()->attempts(SmsGuard::CACHE_PREFIX . 'ip:platform:203.0.113.9:day'))->toBe(1); +}); + +test('sms guard in monitor mode records a pause it would have enforced', function () { + [$guard, , $cache] = sms_guard_test_setup(['mode' => 'monitor', 'limits' => ['destination' => ['cooldown_seconds' => 0, 'per_hour' => null, 'per_day' => null]]]); + $cache->put(SmsGuard::CACHE_PREFIX . 'pause:company:company-1', ['until' => time() + 1800, 'reason' => 'incident', 'auto' => false, 'company_uuid' => 'company-1', 'at' => time()], 1800); + + $reservation = $guard->reserve(sms_guard_context('+447400100123')); + + expect($reservation->decision)->toBe(SmsReservation::DECISION_MONITOR) + ->and($reservation->wouldBlock)->toBe(SmsBlockedException::REASON_PAUSED) + ->and(collect(app('log')->entries)->pluck(1)->all())->toContain('sms.would_block'); +}); + +test('sms guard counts on the configured cache store and falls back to the default store when it is missing', function () { + [, , $cache] = sms_guard_test_setup(['cache_store' => 'sms-counters']); + app()->instance('cache', new SmsGuardTestCacheManager($cache)); + Facade::clearResolvedInstances(); + + $guard = new SmsGuard(); + $guard->limiter()->hit(SmsGuard::CACHE_PREFIX . 'probe', 60); + + expect($cache->get(SmsGuard::CACHE_PREFIX . 'probe'))->toBe(1) + ->and(collect(app('log')->entries)->pluck(1)->all())->toContain('sms.guard_cache_store_unavailable'); + + bind_test_container(['sms.guard' => ['cache_store' => null]]); + app()->instance('cache', new SmsGuardTestCacheManager($cache)); + Facade::clearResolvedInstances(); + + $plain = new SmsGuard(); + $plain->limiter()->hit(SmsGuard::CACHE_PREFIX . 'probe', 60); + + expect($cache->get(SmsGuard::CACHE_PREFIX . 'probe'))->toBe(2) + ->and($plain->breaker())->toBeInstanceOf(SmsBreaker::class) + ->and(app('log')->entries)->toBe([]); +}); diff --git a/tests/Unit/Support/Sms/SmsLedgerTest.php b/tests/Unit/Support/Sms/SmsLedgerTest.php index 192d17c7..ffaafeab 100644 --- a/tests/Unit/Support/Sms/SmsLedgerTest.php +++ b/tests/Unit/Support/Sms/SmsLedgerTest.php @@ -120,3 +120,21 @@ function sms_ledger_context(string $destination = '+447400100123', string $compa expect(SmsLedger::record(sms_ledger_context(), 'reserved'))->toBeString() ->and(SmsSend::count())->toBe(1); }); + +test('sms ledger keeps going when the cache that remembers a failure is itself unreachable', function () { + $capsule = SmsGuardFixture::database(); + $capsule->getConnection('mysql')->getSchemaBuilder()->drop('sms_sends'); + app()->forgetInstance('cache'); + Facade::clearResolvedInstance('cache'); + + expect(SmsLedger::available())->toBeTrue() + ->and(SmsLedger::record(sms_ledger_context(), 'reserved'))->toBeNull() + ->and(SmsLedger::countriesFor('company-1'))->toBe([]) + ->and(SmsLedger::sentCountFor('company-1'))->toBe(0) + // Nothing could remember the failure, so the ledger is still "available" and tries again. + ->and(SmsLedger::available())->toBeTrue(); + + SmsLedger::update('some-uuid', ['status' => 'sent']); + + expect(collect(app('log')->entries)->where(1, 'sms.ledger_unavailable'))->toHaveCount(2); +}); diff --git a/tests/Unit/Support/Sms/SmsPolicyTest.php b/tests/Unit/Support/Sms/SmsPolicyTest.php index 255dc72c..0a0a95ec 100644 --- a/tests/Unit/Support/Sms/SmsPolicyTest.php +++ b/tests/Unit/Support/Sms/SmsPolicyTest.php @@ -3,6 +3,7 @@ use Fleetbase\Models\Setting; use Fleetbase\Support\Sms\SmsContext; use Fleetbase\Support\Sms\SmsPolicy; +use Fleetbase\Tests\Fixtures\Support\SmsGuardFixture; use Illuminate\Cache\ArrayStore; use Illuminate\Cache\Repository as CacheRepository; use Illuminate\Database\Capsule\Manager as Capsule; @@ -253,3 +254,26 @@ function sms_policy_database(array $guard = []): Capsule 'extra' => ['a' => 1], ]); }); + +test('sms policy tolerates a missing cache and tables it cannot query', function () { + bind_test_container(['sms.guard' => sms_policy_config()]); + app()->forgetInstance('cache'); + Facade::clearResolvedInstances(); + + SmsPolicy::flush(); + + expect(SmsPolicy::settings()['mode'])->toBe('enforce'); + + // A database with neither a companies nor a users table: the lookups fail quietly. + SmsGuardFixture::database(['sms.guard' => sms_policy_config()]); + + expect(SmsPolicy::isOnProbation('company-new', 7))->toBeFalse() + ->and(SmsPolicy::adminUsers()->isEmpty())->toBeTrue() + ->and(SmsPolicy::alertRecipients())->toBe(['ops@example.test']); +}); + +test('sms policy drops purposes that are not named or carry no rules', function () { + $normalized = SmsPolicy::normalize(['purposes' => ['' => ['trusted' => true], 'login' => 'yes', 7 => ['trusted' => true], '2fa' => ['trusted' => true]]]); + + expect(array_keys($normalized['purposes']))->toBe(['2fa']); +}); From a8fe816a793f6a2f0c801986fef2b42d4ffc45a1 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 14 Oct 2026 12:46:45 +0800 Subject: [PATCH 51/51] docs(release): v1.6.69 notes cover everything merged since #291 --- RELEASE.md | 37 ++++++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 92381f23..d6d11976 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,28 +1,51 @@ -# v1.6.69 — Authenticated realtime channels, private media, database backups and hashed codes +# v1.6.69 — SMS send guard, authenticated realtime channels, API cache invalidation, comment visibility and database backups ## Added -- **Authenticated realtime channels.** Socket tokens, a channel authorizer with per-resource resolvers, and signed HTTP publish. `POST int/v1/socket/token` mints a user token, and `POST int/v1/socket/authorize` is called only by the socket server, with signed requests. Extensions register channel resolvers for their own resources. It is **off by default** and stays off until `SOCKETCLUSTER_AUTH_ENABLED=true`, even with a key set, so existing socket clients (mobile apps, the console, integrations) keep working. (#290) -- **Database backups.** Settings-driven backups (`db:backup`) on a configurable schedule, with environment defaults in `config/database-backups.php` (`DB_BACKUP_*`) and an admin override. Failures email the configured addresses and are logged. (#288) -- **Hashed one-time codes.** `VerificationCode::issue()` stores an HMAC of the code and returns the plain code once. `check()` counts attempts and reports `valid`, `invalid`, `expired` or `locked`. The existing generators are unchanged. (#289) +- **SMS send guard.** Every SMS passes `Fleetbase\Support\Sms\SmsGuard` before a provider is called, and before a verification code row is written: destination validation with libphonenumber (mobile numbers only; premium-rate, shared-cost, satellite and blocked ranges are refused), a per-destination cooldown and caps, a per-IP layer, store/network and company budgets (200 a day per company by default, 20 for companies younger than 7 days), a platform-wide bucket, and a circuit breaker that pauses a tenant and alerts the system administrators when it uses up its daily budget, hits its hourly budget twice in a day, texts a burst of distinct new numbers, texts a country it has never texted, or issues codes that nobody verifies. Trusted purposes (2FA, admin sends) skip tenant pauses and budgets so staff can still sign in. Refusals are `SmsBlockedException`, rendered in the usual error envelope with 422 (number can never be texted), 429 with `Retry-After` (cooldown or budget), 423 (paused) or 503 (counters unreachable). Limits, mode, pauses and alert recipients live in the `system.sms-guard` setting; `config/sms.php` supplies the defaults, and `sms:mode`, `sms:pause` and `sms:unpause` change them without a deployment. (#305, #307) +- **SMS send ledger.** Every allowed send is reserved in `sms_sends` and settled with the provider outcome, and every refusal is written with its reason, with tenant, purpose, source, client address, destination country and type. `sms:ledger:prune` masks numbers after 30 days and deletes rows after 90. Writes are best effort: a missing table never fails a send. (#307) +- **SMS guard admin API** under `int/v1/sms-guard`: settings (mode, budgets, blocklists, alert recipients, per-company exceptions), reset, pauses (create and lift), usage per organization against its budget, and the ledger with filters. The console page ships in fleetbase/fleetbase v0.7.69. (#307) +- **`SmsServiceChannel`**, a notification channel that delivers through `SmsService`, so notification texts are guarded and ledgered too. (#307) +- **Verification code tracing and single-use hashed codes.** `verification_codes` gains `company_uuid`, `identity`, `channel`, `ip_address`, `user_agent`, `sms_send_uuid` and `consumed_at`. `generateSmsVerificationFor(..., ['hashed' => true])` stores an HMAC and hands the message callback the plain code; `findActiveFor()` and `findActiveForSubject()` return the newest live code, `check()` verifies it and `consume()` uses it up. `VerificationCode::issue()` stores an HMAC and returns the plain code once, and `check()` counts attempts and reports `valid`, `invalid`, `expired` or `locked`. The existing generators are unchanged. (#289, #307) +- **Authenticated realtime channels.** Socket tokens, a channel authorizer with per-resource resolvers, and signed HTTP publish. `POST int/v1/socket/token` mints a user token, `POST v1/socket/system-token` serves platform API callers, and `POST int/v1/socket/authorize` is called only by the socket server, with signed requests. Extensions register channel resolvers for their own resources. It is **off by default** and stays off until `SOCKETCLUSTER_AUTH_ENABLED=true`, even with a key set, so existing socket clients (mobile apps, the console, integrations) keep working. The publisher now sends a configurable `Origin` (`SOCKETCLUSTER_ORIGIN`) on its handshake, so a socket server with restricted origins accepts server broadcasts. (#290) +- **Comment visibility and `comment.created`.** Comments carry `meta.visibility` (`internal` or `customer`), accepted as `visibility` on create. A reply takes its parent's visibility; a comment on an order is `internal` from the console and `customer` from anywhere else; comments on other subjects keep no visibility and stay visible. The public v1 comments API excludes internal comments, and the comment resource gains a top-level `visibility`. A new `comment.created` broadcast goes to `company.{uuid}` for every comment, and customer-visible comments also go to the subject's own channels (`order.order_x`). (#306) +- **Chat participant roles and system messages.** `chat_participants` and `chat_messages` gain a `meta` column; participants expose `role` (`customer`, `driver`, `store`, `system`) and messages expose `system` and `meta` (for example `kind: order_updated`). (#306) +- **API cache dependencies.** Cache keys carry the versions of every table they embed, so a write to a related table invalidates the lists, records and relationships that embed it. Dependencies come from `protected $apiCacheDependencies` on the model, the request's `with`, `expand` and counts, and the lookups' own paths. `withoutInvalidating()` scopes coalesced high-frequency writes, and `API_CACHE_STRICT_DEPENDENCIES` makes a bad declaration throw in test suites. (#301) +- **Database backups.** Settings-driven backups (`db:backup`) on a configurable schedule, with environment defaults in `config/database-backups.php` (`DB_BACKUP_*`) and an admin API under `int/v1/database-backups`. Failures email the configured addresses and are logged. (#288, #295, #296) +- `LogApiRequest::RELATED_ATTRIBUTE` (`api_request_log_related`): an extension sets it on the request and the ids land in the log row's `related` column, so a storefront request traces back to its store or network key. (#302) ## Changed +- **API cache invalidation.** Invalidation now changes the keys of every cache: lists moved on the first write only after a second write, and single records and relationships relied on a tag flush that leaves values behind on phpredis 6.1 and later. Versioned keys are the invalidation mechanism, list keys cover the request path and the session scope (`api.cache.scope.session`), writes inside a transaction bump again on commit, and an invalidation failure never fails the write that triggered it. On deploy every key changes once. (#299, #300, #301) +- **API request logs.** Any route that attaches `LogApiRequests` is logged, not only routes under the core `v1/` prefix, so storefront API writes are traceable. Internal routes are still skipped. (#302) +- **`without[]`** uses `makeHidden()`, so it no longer un-hides columns the model hides. Requested relation names come back as declared, so `subfleets` loads as `subFleets` and renders. `withCounts()` accepts an array. (#303) - **Private media buckets.** Stored file URLs that point into the configured `s3` bucket are signed again on read, so the bucket can be fully private. (#287) - **Faster lookups.** The country lookup is cached, and `files.subject_uuid` is indexed. (#291) - The admin SocketCluster test always publishes to `test.{current user uuid}` and returns the channel it used. (#290) +- The manual database backup is dispatched through the bus, and `DB_BACKUP_BUCKET` no longer has a hard-coded default: with no bucket set, the `s3` disk's own bucket is used. (#295, #296) + +## Security + +- **Requested relations must be relations.** `with`, `expand`, `with_count` and `count` reached Eloquent unchecked, so a request could run any public method with no required arguments on a model (`with[]=dispatch` dispatched a blank order). A name is now accepted only when the method declares an Eloquent `Relation` return type (plain or union), is an `Expandable` closure that does, or is listed in the model's `protected $withable`. Nested paths are checked segment by segment and cut at the first that fails; rejected names are dropped without an error. (#303) +- **Only registered notifiables are resolved** from saved notification settings. A stored `dynamic:` name or class that no extension registered through `NotificationRegistry::registerNotifiable()` is skipped instead of being called or built. (#304) +- **Deletes are scoped to the company.** `deleteRecord` compared a qualified column name against the table's columns and never matched, so the company constraint was skipped. Reports are filtered by company through the new `ReportFilter`. +- **A failed comment publish no longer leaves every model unguarded** in a long-lived Octane worker. (#298) +- Order lifecycle events reach `driverAssigned.{driver uuid}` again; they went to a dead `driverAssigned.` channel. (#306) ## Removed - `MysqlS3Backup`, `S3BackupTrimmer` and `config/laravel-mysql-s3-backup.php`, replaced by the new database backups. (#288) +- The unrouted legacy Twilio-direct SMS login methods on `AuthController`. (#307) ## Upgrade Steps -- Run migrations: `database_backups` table (#288) and the `files.subject_uuid` index (#291). -- Socket auth (#290) adds `SOCKETCLUSTER_AUTH_ENABLED` (default `false`), `SOCKETCLUSTER_AUTH_KEY`, `SOCKETCLUSTER_PUBLISH_URL` (default `http://{SOCKETCLUSTER_HOST}:8001`) and `SOCKETCLUSTER_TOKEN_TTL` (default 900) to `broadcasting.connections.socketcluster`. Nothing changes for socket clients until `SOCKETCLUSTER_AUTH_ENABLED=true`. Roll out in this order: +- Run migrations: `database_backups` (#288), the `files.subject_uuid` index (#291), `meta` on `chat_participants` and `chat_messages` (#306), `sms_sends` and the `verification_codes` tracing columns (#307). Existing comments and codes are untouched. +- The SMS guard is **on** (`SMS_GUARD_ENABLED`, default `true`) in `enforce` mode (`SMS_GUARD_MODE`). To watch before refusing, deploy with `SMS_GUARD_MODE=monitor` or run `php artisan sms:mode monitor`, review the `sms.would_block` log entries, then `php artisan sms:mode enforce`. The counters must live in a store shared by every worker (Redis in production); `SMS_GUARD_CACHE_STORE` pins one. Set `SMS_GUARD_ALERT_EMAILS` for alerts beyond the system administrators, and `SMS_GUARD_BLOCKED_COUNTRIES` / `SMS_GUARD_BLOCKED_PREFIXES` as needed. Schedule `php artisan sms:ledger:prune` daily (`SMS_GUARD_LEDGER_RETENTION_DAYS`, `SMS_GUARD_LEDGER_MASK_AFTER_DAYS`). +- Socket auth (#290) adds `SOCKETCLUSTER_AUTH_ENABLED` (default `false`), `SOCKETCLUSTER_AUTH_KEY`, `SOCKETCLUSTER_PUBLISH_URL` (default `http://{SOCKETCLUSTER_HOST}:8001`), `SOCKETCLUSTER_TOKEN_TTL` (default 900) and `SOCKETCLUSTER_ORIGIN` to `broadcasting.connections.socketcluster`. Set `SOCKETCLUSTER_ORIGIN` to the console origin when the socket server restricts origins. Nothing changes for socket clients until `SOCKETCLUSTER_AUTH_ENABLED=true`. Roll out in this order: 1. Ship clients that fall back to connecting without a token when the token route answers 404. 2. Set `SOCKETCLUSTER_AUTH_ENABLED=true` on the API and the socket server, with the socket server in `log` mode. 3. Switch the socket server to `enforce`. +- Extensions: a relation that clients request through `with`, `expand` or counts must declare an Eloquent `Relation` return type or be listed in `$withable`, or it stops loading (#303). Every `dynamic:` notifiable an extension resolves must be passed to `registerNotifiable()` (#304). Models whose cached queries eager-load other tables should declare `$apiCacheDependencies`, and writes that skip Eloquent events need an explicit invalidation (#301). fleetbase/fleetops v0.6.72 and fleetbase/storefront v0.4.25 carry these and require this release (`fleetbase/core-api ^1.6.69`). +- Public API clients: `GET v1/comments` and `GET v1/comments/{id}` no longer return internal comments, `POST v1/comments` accepts `visibility`, and comment, chat participant and chat message responses gain fields (#306). - To make the media bucket private, remove any public `s3:GetObject` statement from the bucket policy and turn on Block Public Access (#287). - Database backups replace the old S3 backup settings; configure them with `DB_BACKUP_*` or in the admin settings (#288). -- fleetbase/storefront v0.4.25 and fleetbase/fleetops#358 require this release (`fleetbase/core-api ^1.6.69`).