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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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);