diff --git a/README.md b/README.md index c0adb0e6..dbbb70c2 100644 --- a/README.md +++ b/README.md @@ -109,3 +109,42 @@ 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 + +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. | +| `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. + +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. diff --git a/RELEASE.md b/RELEASE.md index 149eb425..92381f23 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,26 +1,28 @@ -# 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** 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) ## 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_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`). 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", diff --git a/config/broadcasting.connections.php b/config/broadcasting.connections.php index bfe48c88..f124df25 100644 --- a/config/broadcasting.connections.php +++ b/config/broadcasting.connections.php @@ -22,7 +22,21 @@ '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 + // 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), ], // for apple apn 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_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; + } + } +}; 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/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 @@ +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 + { + app(Dispatcher::class)->dispatch(new RunDatabaseBackup(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/Http/Controllers/Internal/v1/SettingController.php b/src/Http/Controllers/Internal/v1/SettingController.php index ac994e1c..be04ac17 100644 --- a/src/Http/Controllers/Internal/v1/SettingController.php +++ b/src/Http/Controllers/Internal/v1/SettingController.php @@ -1040,14 +1040,31 @@ public function testSentryConfig(AdminRequest $request) /** * Test SocketCluster Configuration. * + * 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 * * @return \Illuminate\Http\JsonResponse returns a JSON response with a success message and HTTP status 200 */ public function testSocketcluster(AdminRequest $request) { + $userUuid = session('user'); + $scoped = \Fleetbase\Support\SocketCluster\SocketToken::enabled(); + + if ($scoped && (!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 = $scoped ? 'test.' . $userUuid : (string) $request->input('channel', 'test'); $message = 'Socket broadcasted message successfully.'; $status = 'success'; $sent = false; 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/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/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/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/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/Providers/SocketClusterServiceProvider.php b/src/Providers/SocketClusterServiceProvider.php index c0c0e96c..f08e4d2a 100644 --- a/src/Providers/SocketClusterServiceProvider.php +++ b/src/Providers/SocketClusterServiceProvider.php @@ -2,6 +2,9 @@ namespace Fleetbase\Providers; +use Fleetbase\Support\SocketCluster\ChannelAuthorizer; +use Fleetbase\Support\SocketCluster\CoreChannelResolvers; +use Fleetbase\Support\SocketCluster\SocketChannelRegistry; use Fleetbase\Support\SocketCluster\SocketClusterBroadcaster; use Fleetbase\Support\SocketCluster\SocketClusterService; use Illuminate\Support\Facades\Broadcast; @@ -9,6 +12,28 @@ class SocketClusterServiceProvider extends ServiceProvider { + /** + * Register the realtime channel registry and authorizer. + * + * Singletons: extensions add their channel resolvers to the registry from their own + * service providers, and core's resolvers are registered when it is first built. + * + * @return void + */ + public function register() + { + $this->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/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/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/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/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..66279ce5 --- /dev/null +++ b/src/Support/SocketCluster/ModelChannelResolver.php @@ -0,0 +1,67 @@ +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). + * + * 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); + }) + ->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/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/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..c9115907 --- /dev/null +++ b/src/Support/SocketCluster/SocketToken.php @@ -0,0 +1,226 @@ += 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::switchedOn() && 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/src/Support/Utils.php b/src/Support/Utils.php index a26b916f..c44baa8c 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); @@ -1791,8 +1845,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/src/routes.php b/src/routes.php index fe76c767..1a123c0c 100644 --- a/src/routes.php +++ b/src/routes.php @@ -35,12 +35,16 @@ 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') ->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 +167,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 +184,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) { @@ -254,6 +264,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/Fixtures/Support/SocketAuthFixtures.php b/tests/Fixtures/Support/SocketAuthFixtures.php new file mode 100644 index 00000000..22cf4f10 --- /dev/null +++ b/tests/Fixtures/Support/SocketAuthFixtures.php @@ -0,0 +1,325 @@ + $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, + '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/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..820ac9b2 --- /dev/null +++ b/tests/Unit/DatabaseBackupsTest.php @@ -0,0 +1,944 @@ +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' => '']; + $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' => $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())); + $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']); + + $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']]]); + + $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]]); + + [$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]); + + $service = new DatabaseBackupsServiceStub([]); + [$code, $display] = database_backups_command($service); + expect($code)->toBe(0) + ->and($display)->toContain('Database backups are disabled.') + ->and($service->calls)->toBe([]); + + $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(); + $service = new DatabaseBackupsServiceStub([]); + $job->handle($service); + + 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/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/SettingControllerExternalProbesTest.php b/tests/Unit/Http/SettingControllerExternalProbesTest.php index 4f6c04ee..dd7538d3 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, @@ -168,9 +169,11 @@ 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()->flush(); + // 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' => 'settings-probe', ])); @@ -182,6 +185,66 @@ function setting_controller_external_probe_request(array $input = []): AdminRequ 'channel' => 'settings-probe', 'response' => null, ]); + + $default = (new SettingController())->testSocketcluster(setting_controller_external_probe_request()); + + expect($default->getData(true)['channel'])->toBe('test'); +}); + +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' => '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/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/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(); 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); +}); 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/RoutesContractTest.php b/tests/Unit/RoutesContractTest.php index f7e8fe2e..0d51d40c 100644 --- a/tests/Unit/RoutesContractTest.php +++ b/tests/Unit/RoutesContractTest.php @@ -365,6 +365,28 @@ 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'); + $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') + ->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') + ->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]); + }); + 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'; @@ -385,4 +407,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'); + } + }); } diff --git a/tests/Unit/Support/SocketChannelAuthorizationTest.php b/tests/Unit/Support/SocketChannelAuthorizationTest.php new file mode 100644 index 00000000..83d796f0 --- /dev/null +++ b/tests/Unit/Support/SocketChannelAuthorizationTest.php @@ -0,0 +1,392 @@ +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); +} + +/** + * 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(); +}); + +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'], false); + $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']]); + $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 () { + SocketAuthFixtures::database(); + + $authorizer = socket_channel_core_authorizer(); + $driver = socket_channel_driver(); + $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() + ->and(CoreChannelResolvers::isChatParticipant($driver, null))->toBeFalse(); +}); 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'); +}); 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); diff --git a/tests/Unit/Support/SocketTokenTest.php b/tests/Unit/Support/SocketTokenTest.php new file mode 100644 index 00000000..68b98032 --- /dev/null +++ b/tests/Unit/Support/SocketTokenTest.php @@ -0,0 +1,408 @@ + true]); + + expect(SocketToken::key())->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_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); + + 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']); +}); 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(); +});