diff --git a/CHANGELOG.md b/CHANGELOG.md index db652f5e..2f4ccd03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,17 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - `FleetbaseResource::resolve()` applies registered transformers to every resource, nested resource and collection item; `FleetbaseResourceCollection` resolves items (instead of calling `toArray()`), sharing one `prepare()` pass per collection. A hand-built collection with a manually set `preserveKeys` now filters item arrays with the item's flag. - `ResourceLifecycleEvent::getEventData()` and `broadcastWith()`, chat participant broadcast events, `Utils::serializeJsonResource()` and the cached internal user payload serialize through `resolve()`, so transformers apply to webhook and broadcast payloads and conditional `MissingValue`s are no longer emitted as `{}`. - `Find::httpResourceForModel()` caches internal and public resolutions separately. +- Untyped relation methods can no longer be requested by a client (see Security). Give the method a return type (`public function driver(): BelongsTo`) or list its name in `protected $withable = [...]`. +- A relation requested in another case (`subfleets`) loads under the declared method name (`subFleets`), the key resources read. +- `without` on create, update and public list responses adds to the model's hidden attributes (`makeHidden`) instead of replacing them (`setHidden`), so it can no longer un-hide the model's defaults. + +### Security +- Request `with` / `expand` / `with_count` / `count` names, and a template query's stored `with` list, reach Eloquent only when they name a real relation. That means a method or Expandable closure that declares an Eloquent Relation return type (a union counts when one member is a Relation), or a name the model lists in a new `$withable` array. Each segment of a nested path is checked against the related model, and the path is cut at the first segment that fails. Nothing after a `morphTo` is kept, and a `morphTo` is never counted. Anything else is dropped without an error. Previously Eloquent called any requested public method with no required arguments on a blank model: `with[]=saveInstance` inserted an empty row, and fleetops' `Order::dispatch()` saved an order and queued its events. See `Fleetbase\Support\RequestRelations`. ### Removed +- `HasApiModelBehavior::normalizeRequestRelations()` (protected, no callers outside the trait). - Legacy duck-typed transformers (`$target` property + static `output($model, $data)`), `ResourceTransformerRegistry::transform(Model, array)`, `resolveByTarget()`, `fixClassName()` and the static `$transformers` array. The `User` resource no longer calls the registry directly. - Adds first version + +### Security +- `NotificationRegistry` only resolves notifiables registered with `registerNotifiable()`. A company's saved notification settings could name any `dynamic:`, which reached any public no-argument method on the notification subject (for example fleetops `Order::dispatch()`), or any class name, which the container built. Unregistered definitions are now skipped; extensions must register every `dynamic:` name and notifiable class they expect to receive. diff --git a/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/api.php b/config/api.php index 826cc4cc..2f962b39 100644 --- a/config/api.php +++ b/config/api.php @@ -60,6 +60,22 @@ 'relationship' => env('API_CACHE_RELATIONSHIP_TTL', 1800), ], + // List cache scope below the company + 'scope' => [ + // Session keys whose values are part of every list cache key, so lists + // filtered on them are not shared between their values. Add any key a + // query callback reads from the session but not from the query string. + // Default: the API credential and the Storefront store and network + 'session' => ['api_credential', 'storefront_store', 'storefront_network'], + ], + + // Throw when a model's $apiCacheDependencies entry cannot be resolved, instead + // of logging it and skipping the entry. Meant for test suites; keep it off in + // production so an extension's bad declaration never fails a request. + // Default: false + // Example: API_CACHE_STRICT_DEPENDENCIES=true + 'strict_dependencies' => env('API_CACHE_STRICT_DEPENDENCIES', false), + // Cache driver (uses Laravel's cache configuration) // Options: redis, memcached, database, file // Default: uses config('cache.default') diff --git a/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..d46a5f6b --- /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'), + '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/config/sms.php b/config/sms.php index 3d6749cf..4f9316a8 100644 --- a/config/sms.php +++ b/config/sms.php @@ -135,4 +135,94 @@ ], ], ], + + /* + |-------------------------------------------------------------------------- + | Send Guard + |-------------------------------------------------------------------------- + | + | Every SMS passes through Fleetbase\Support\Sms\SmsGuard before a provider + | is called (and, for verification codes, before the code row is written). + | The guard validates the destination with libphonenumber, enforces + | per-destination cooldowns and caps, per-tenant and platform-wide budgets, + | and pauses a tenant automatically when it crosses its budget or texts a + | burst of new numbers. This is what bounds the damage of a leaked public + | storefront key: see docs/security/storefront-sms-abuse-proposal.md. + | + | Everything here is a DEFAULT. A system administrator can change any of it + | at runtime (the stored override lives in the `system.sms-guard` setting and + | applies within a minute), so a deployment is never needed to switch modes, + | raise a tenant's budget or pause one. + | + | mode: "enforce" blocks, "monitor" only logs what would have been blocked. + | limits: counted in sends. null means no limit for that window. + | company: the default per-tenant budget; an administrator adds exceptions + | per company (raise, lower, unlimited). + | tenant: a store or network inside a company; null inherits the company's. + | probation: companies younger than `days` get this smaller daily budget, + | so a self-signup farm cannot mint a fresh budget per account. + | platform: enforced for all tenants together, deliberately high relative + | to a single tenant's cap. The "trusted" bucket is for purposes + | that already proved a password (2FA, admin actions). + | + */ + + 'guard' => [ + 'enabled' => env('SMS_GUARD_ENABLED', true), + 'mode' => env('SMS_GUARD_MODE', 'enforce'), + 'cache_store' => env('SMS_GUARD_CACHE_STORE'), + 'alert_emails' => env('SMS_GUARD_ALERT_EMAILS', ''), + + 'validation' => [ + 'enabled' => env('SMS_GUARD_VALIDATE', true), + 'allowed_types' => ['MOBILE', 'FIXED_LINE_OR_MOBILE'], + 'blocked_countries' => env('SMS_GUARD_BLOCKED_COUNTRIES', ''), + // International freephone, shared-cost, satellite, personal-number and + // premium ranges: the destinations that pay out per message. + 'blocked_prefixes' => env('SMS_GUARD_BLOCKED_PREFIXES', '+800,+808,+870,+878,+881,+882,+883,+888,+979'), + ], + + 'limits' => [ + 'destination' => ['cooldown_seconds' => 60, 'per_hour' => 3, 'per_day' => 5], + 'ip' => ['per_15_minutes' => 5, 'per_day' => 20], + 'company' => ['per_minute' => 10, 'per_hour' => 60, 'per_day' => 200], + 'tenant' => ['per_minute' => null, 'per_hour' => null, 'per_day' => null], + 'probation' => ['days' => 7, 'per_day' => 20], + 'platform' => [ + 'public' => ['per_minute' => 100, 'per_hour' => 1500, 'per_day' => 10000], + 'trusted' => ['per_minute' => 100, 'per_hour' => 1500, 'per_day' => 10000], + ], + ], + + 'breaker' => [ + 'pause_minutes' => 60, + 'alert_percentages' => [50, 80], + 'distinct_destinations_per_10m' => 25, + 'auto_pauses_before_manual' => 2, + // Sends in 10 minutes to a country the company has not texted in the last + // 30 days, once it has at least `new_country_min_history` ledgered sends. + 'new_country_min_sends' => 10, + 'new_country_min_history' => 50, + // Verification codes issued by SMS in the last hour versus codes actually + // verified: pumping has a conversion of about zero. + 'conversion_min_issued' => 20, + 'conversion_min_percent' => 10, + ], + + // The send ledger (sms_sends): every send and refusal with who, where and why. + 'ledger' => [ + 'retention_days' => env('SMS_GUARD_LEDGER_RETENTION_DAYS', 90), + 'mask_after_days' => env('SMS_GUARD_LEDGER_MASK_AFTER_DAYS', 30), + ], + + // Per-purpose overrides, keyed by the verification code's `for` value. + 'purposes' => [ + '2fa' => ['trusted' => true, 'destination' => ['cooldown_seconds' => 20, 'per_hour' => 10, 'per_day' => 30]], + 'admin_test' => ['trusted' => true], + 'admin_user_verification' => ['trusted' => true], + 'storefront_login' => ['destination' => ['cooldown_seconds' => 30, 'per_hour' => 5, 'per_day' => 10]], + 'fleetops_customer_login' => ['destination' => ['cooldown_seconds' => 30, 'per_hour' => 5, 'per_day' => 10]], + 'driver_login' => ['destination' => ['cooldown_seconds' => 30, 'per_hour' => 5, 'per_day' => 10]], + ], + ], ]; diff --git a/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/migrations/2026_10_13_000000_add_meta_to_chat_participants_and_chat_messages_tables.php b/migrations/2026_10_13_000000_add_meta_to_chat_participants_and_chat_messages_tables.php new file mode 100644 index 00000000..569b83f7 --- /dev/null +++ b/migrations/2026_10_13_000000_add_meta_to_chat_participants_and_chat_messages_tables.php @@ -0,0 +1,42 @@ +json('meta')->nullable()->after('chat_channel_uuid'); + }); + } + } + + public function down(): void + { + foreach (['chat_participants', 'chat_messages'] as $tableName) { + if (!Schema::hasColumn($tableName, 'meta')) { + continue; + } + + Schema::table($tableName, function (Blueprint $table) { + $table->dropColumn('meta'); + }); + } + } +}; diff --git a/migrations/2026_10_13_000001_create_sms_sends_table.php b/migrations/2026_10_13_000001_create_sms_sends_table.php new file mode 100644 index 00000000..e0db18ee --- /dev/null +++ b/migrations/2026_10_13_000001_create_sms_sends_table.php @@ -0,0 +1,56 @@ +uuid('uuid')->primary(); + $table->char('company_uuid', 36)->nullable()->index(); + $table->string('tenant_type', 32)->nullable(); + $table->char('tenant_uuid', 36)->nullable()->index(); + $table->string('purpose', 64)->index(); + $table->string('source', 32); + $table->string('destination', 32)->nullable(); + $table->string('destination_hash', 64)->nullable()->index(); + $table->char('destination_country', 3)->nullable()->index(); + $table->string('destination_type', 32)->nullable(); + $table->string('status', 16)->index(); + $table->string('block_reason', 64)->nullable(); + $table->string('provider', 32)->nullable(); + $table->string('provider_message_id', 128)->nullable(); + $table->text('error')->nullable(); + $table->string('subject_type')->nullable(); + $table->char('subject_uuid', 36)->nullable(); + $table->char('verification_code_uuid', 36)->nullable()->index(); + $table->string('ip_address', 45)->nullable()->index(); + $table->string('user_agent', 512)->nullable(); + $table->json('meta')->nullable(); + $table->timestamps(); + + $table->index(['company_uuid', 'created_at']); + $table->index(['tenant_uuid', 'created_at']); + $table->index(['status', 'created_at']); + $table->index('created_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('sms_sends'); + } +}; diff --git a/migrations/2026_10_13_000002_add_tracing_columns_to_verification_codes_table.php b/migrations/2026_10_13_000002_add_tracing_columns_to_verification_codes_table.php new file mode 100644 index 00000000..5eb75bf8 --- /dev/null +++ b/migrations/2026_10_13_000002_add_tracing_columns_to_verification_codes_table.php @@ -0,0 +1,50 @@ +identity` for recent rows + * so lookups can move off the JSON path at once. + */ + public function up(): void + { + Schema::table('verification_codes', function (Blueprint $table) { + $table->char('company_uuid', 36)->nullable()->index()->after('subject_type'); + $table->string('identity', 191)->nullable()->after('company_uuid'); + $table->string('channel', 16)->nullable()->after('identity'); + $table->string('ip_address', 45)->nullable()->after('channel'); + $table->string('user_agent', 512)->nullable()->after('ip_address'); + $table->char('sms_send_uuid', 36)->nullable()->index()->after('user_agent'); + $table->dateTime('consumed_at')->nullable()->after('expires_at'); + + $table->index(['for', 'identity', 'created_at']); + }); + + if (DB::connection()->getDriverName() === 'mysql') { + DB::table('verification_codes') + ->whereNull('identity') + ->where('created_at', '>=', now()->subDays(30)) + ->update(['identity' => DB::raw("JSON_UNQUOTE(JSON_EXTRACT(meta, '$.identity'))")]); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('verification_codes', function (Blueprint $table) { + $table->dropIndex(['for', 'identity', 'created_at']); + $table->dropColumn(['company_uuid', 'identity', 'channel', 'ip_address', 'user_agent', 'sms_send_uuid', 'consumed_at']); + }); + } +}; diff --git a/src/Console/Commands/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/Console/Commands/SmsGuardMode.php b/src/Console/Commands/SmsGuardMode.php new file mode 100644 index 00000000..46643e1b --- /dev/null +++ b/src/Console/Commands/SmsGuardMode.php @@ -0,0 +1,59 @@ +argument('mode'); + + if ($mode !== null) { + $mode = strtolower(trim((string) $mode)); + if (!in_array($mode, [SmsPolicy::MODE_ENFORCE, SmsPolicy::MODE_MONITOR], true)) { + $this->error('Mode must be "enforce" or "monitor".'); + + return self::FAILURE; + } + + SmsPolicy::update(['mode' => $mode]); + $this->info('SMS guard mode set to ' . $mode . '.'); + } + + $settings = SmsPolicy::settings(); + $this->line('Enabled: ' . ($settings['enabled'] ? 'yes' : 'no')); + $this->line('Mode: ' . $settings['mode']); + + $pauses = SmsGuard::make()->breaker()->pauses(); + if (!$pauses) { + $this->line('Pauses: none'); + + return self::SUCCESS; + } + + $this->line('Pauses:'); + $this->table(['Scope', 'Until', 'Reason', 'Automatic'], collect($pauses)->map(fn ($pause, $scope) => [ + $scope, + $pause['until'] === null ? 'unpaused manually' : date('c', $pause['until']), + $pause['reason'], + $pause['auto'] ? 'yes' : 'no', + ])->values()->all()); + + return self::SUCCESS; + } +} diff --git a/src/Console/Commands/SmsGuardPause.php b/src/Console/Commands/SmsGuardPause.php new file mode 100644 index 00000000..b7e77348 --- /dev/null +++ b/src/Console/Commands/SmsGuardPause.php @@ -0,0 +1,55 @@ + or tenant:store:} + {--minutes= : how long to pause for; omit to pause until sms:unpause} + {--reason=manual : why, shown on the admin page and in alerts}'; + + protected $description = 'Pause SMS sending for a company, a store/network or the platform'; + + public function handle(): int + { + $scope = static::resolveScope((string) $this->argument('scope')); + if ($scope === null) { + $this->error('No company matches "' . $this->argument('scope') . '". Pass a company uuid or public id, "platform", or a scope key.'); + + return self::FAILURE; + } + + $minutes = $this->option('minutes'); + $minutes = $minutes === null || $minutes === '' ? null : max(1, (int) $minutes); + + $pause = SmsGuard::make()->breaker()->pause($scope, $minutes, (string) $this->option('reason'), false, static::companyUuidFromScope($scope)); + + $this->info('SMS paused for ' . $scope . ($pause['until'] === null ? ' until sms:unpause is run.' : ' until ' . date('c', $pause['until']) . '.')); + + return self::SUCCESS; + } + + /** + * A scope key for what the operator typed, or null when a company could not be found. + */ + public static function resolveScope(string $input): ?string + { + return SmsBreaker::resolveScope($input); + } + + public static function companyUuidFromScope(string $scope): ?string + { + return SmsBreaker::companyUuidFromScope($scope); + } +} diff --git a/src/Console/Commands/SmsGuardUnpause.php b/src/Console/Commands/SmsGuardUnpause.php new file mode 100644 index 00000000..50f0ca39 --- /dev/null +++ b/src/Console/Commands/SmsGuardUnpause.php @@ -0,0 +1,31 @@ +}'; + + protected $description = 'Resume SMS sending for a paused company, store/network or the platform'; + + public function handle(): int + { + $scope = SmsGuardPause::resolveScope((string) $this->argument('scope')); + if ($scope === null) { + $this->error('No company matches "' . $this->argument('scope') . '".'); + + return self::FAILURE; + } + + SmsGuard::make()->breaker()->unpause($scope); + $this->info('SMS resumed for ' . $scope . '.'); + + return self::SUCCESS; + } +} diff --git a/src/Console/Commands/SmsLedgerPrune.php b/src/Console/Commands/SmsLedgerPrune.php new file mode 100644 index 00000000..bb11744b --- /dev/null +++ b/src/Console/Commands/SmsLedgerPrune.php @@ -0,0 +1,36 @@ +option('days') !== null ? (int) $this->option('days') : (int) config('sms.guard.ledger.retention_days', 90); + $maskAfter = $this->option('mask-after') !== null ? (int) $this->option('mask-after') : (int) config('sms.guard.ledger.mask_after_days', 30); + + try { + $result = SmsLedger::prune($retention, $maskAfter); + } catch (\Throwable $e) { + $this->error('The SMS ledger could not be pruned: ' . $e->getMessage()); + + return self::FAILURE; + } + + $this->info(sprintf('SMS ledger: masked %d row(s) older than %d day(s), deleted %d row(s) older than %d day(s).', $result['masked'], $maskAfter, $result['deleted'], $retention)); + + return self::SUCCESS; + } +} diff --git a/src/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 @@ +eventId = uniqid('event_'); + $this->createdAt = Carbon::now(); + $this->comment = $comment; + } + + /** + * The event's broadcast name. + */ + public function broadcastAs(): string + { + return 'comment.created'; + } + + /** + * Get the channels the event should broadcast on. + * + * @return array + */ + public function broadcastOn(): array + { + $channels = []; + + if ($this->comment->company_uuid) { + $channels[] = new Channel('company.' . $this->comment->company_uuid); + } + + if ($this->comment->isVisibleToCustomers()) { + foreach ($this->subjectChannelNames() as $name) { + $channels[] = new Channel($name); + } + } + + return $channels; + } + + /** + * The subject's channels: `{subject type}.{public_id}` and `{subject type}.{uuid}`, the same names + * the subject's own lifecycle events use (`order.order_x`). + * + * @return array + */ + public function subjectChannelNames(): array + { + $subjectType = $this->comment->subject_type; + if (!$subjectType || !$this->comment->subject_uuid) { + return []; + } + + $prefix = Str::snake(class_basename(ltrim($subjectType, '\\'))); + $subject = $this->resolveSubject(); + $publicId = $subject ? data_get($subject, 'public_id') : null; + if (!$publicId) { + return []; + } + + return [$prefix . '.' . $publicId, $prefix . '.' . $this->comment->subject_uuid]; + } + + /** + * Resolve the commented subject, ignoring global scopes and failures (the broadcast is best effort). + */ + protected function resolveSubject(): mixed + { + try { + if ($this->comment->relationLoaded('subject')) { + return $this->comment->getRelation('subject'); + } + + $subjectType = ltrim($this->comment->subject_type, '\\'); + if (!class_exists($subjectType)) { + return null; + } + + return (new $subjectType())->newQuery()->withoutGlobalScopes()->where('uuid', $this->comment->subject_uuid)->first(); + } catch (\Throwable $e) { + return null; + } + } + + /** + * Get the data to broadcast. + */ + public function broadcastWith(): array + { + $resource = new CommentResource($this->comment); + + return [ + 'id' => $this->eventId, + 'event' => $this->broadcastAs(), + 'created_at' => $this->createdAt->toDateTimeString(), + 'subject_id' => data_get($this->resolveSubject(), 'public_id'), + 'data' => $resource->resolveFor(ResourceTransformerContext::BROADCAST, request()), + ]; + } +} diff --git a/src/Events/ResourceLifecycleEvent.php b/src/Events/ResourceLifecycleEvent.php index cddc2f8c..5a9e444c 100644 --- a/src/Events/ResourceLifecycleEvent.php +++ b/src/Events/ResourceLifecycleEvent.php @@ -225,8 +225,10 @@ protected function addRelationshipSpecificChannels($model, &$channels) { $relationships = ['driverAssigned', 'customer', 'facilitator', 'vendor']; foreach ($relationships as $relationship) { - if ($model && isset($model->{Str::snake($relationship) . '_uuid'})) { - $channels[] = new Channel($relationship . '.' . $model->{$relationship . '_uuid'}); + // Foreign keys are snake case (`driver_assigned_uuid`), the relationship names are camel case. + $foreignKey = Str::snake($relationship) . '_uuid'; + if ($model && isset($model->{$foreignKey})) { + $channels[] = new Channel($relationship . '.' . $model->{$foreignKey}); } if ($model && isset($model->{$relationship})) { $channels[] = new Channel($relationship . '.' . $model->{$relationship}->public_id); diff --git a/src/Exceptions/Handler.php b/src/Exceptions/Handler.php index 4abc3167..467861b0 100644 --- a/src/Exceptions/Handler.php +++ b/src/Exceptions/Handler.php @@ -15,7 +15,9 @@ class Handler extends ExceptionHandler * * @var array */ - protected $dontReport = []; + protected $dontReport = [ + SmsBlockedException::class, + ]; /** * A list of the inputs that are never flashed for validation exceptions. @@ -79,6 +81,12 @@ public function report(\Throwable $exception) */ public function render($request, \Throwable $exception) { + // A refused SMS answers with its own status (422 bad number, 429 limited with + // Retry-After, 423 paused) and the same error envelope as every other API error. + if ($exception instanceof SmsBlockedException) { + return response()->json(['errors' => [$exception->getMessage()]], $exception->status(), $exception->headers()); + } + if ($this->shouldManuallyHandleException($exception)) { return $this->manuallyHandleException($exception); } diff --git a/src/Exceptions/SmsBlockedException.php b/src/Exceptions/SmsBlockedException.php new file mode 100644 index 00000000..d792f219 --- /dev/null +++ b/src/Exceptions/SmsBlockedException.php @@ -0,0 +1,78 @@ + $scope])); + } + + public static function unavailable(array $context = []): static + { + return new static(self::REASON_UNAVAILABLE, 'Text messages are temporarily unavailable. Try again later.', 503, 60, $context); + } + + public function reason(): string + { + return $this->reason; + } + + public function status(): int + { + return $this->status; + } + + public function retryAfter(): ?int + { + return $this->retryAfter; + } + + public function context(): array + { + return $this->context; + } + + /** + * Headers for the HTTP response: Retry-After when there is a time to wait. + */ + public function headers(): array + { + return $this->retryAfter !== null ? ['Retry-After' => (string) $this->retryAfter] : []; + } +} diff --git a/src/Http/Controllers/Api/v1/CommentController.php b/src/Http/Controllers/Api/v1/CommentController.php index 24fd93ce..88cfbdec 100644 --- a/src/Http/Controllers/Api/v1/CommentController.php +++ b/src/Http/Controllers/Api/v1/CommentController.php @@ -35,6 +35,11 @@ public function create(CreateCommentRequest $request) 'content' => $content, ]; + // Optional `internal` | `customer`; unset falls back to the subject's default visibility. + if (in_array($request->input('visibility'), Comment::VISIBILITIES, true)) { + $data['visibility'] = $request->input('visibility'); + } + // Resolve the parent $parentComment = null; if ($parent) { @@ -144,9 +149,9 @@ public function query(Request $request) */ public function find($id) { - // find for the Comment + // find for the Comment, internal notes are not exposed on the public API try { - $comment = Comment::findRecordOrFail($id); + $comment = Comment::findRecordOrFail($id, [], ['*'], fn ($query) => $query->visibleToCustomers()); } catch (ModelNotFoundException $exception) { return response()->apiError('Comment resource not found.', 404); } catch (\Throwable $e) { diff --git a/src/Http/Controllers/Internal/v1/AuthController.php b/src/Http/Controllers/Internal/v1/AuthController.php index c7583fa5..844d588c 100644 --- a/src/Http/Controllers/Internal/v1/AuthController.php +++ b/src/Http/Controllers/Internal/v1/AuthController.php @@ -26,7 +26,6 @@ use Fleetbase\Support\Auth; use Fleetbase\Support\TwoFactorAuth; use Fleetbase\Support\Utils; -use Fleetbase\Twilio\Support\Laravel\Facade as Twilio; use Illuminate\Http\Request; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Cache; @@ -242,133 +241,6 @@ public function bootstrap(Request $request) ->header('Cache-Control', 'private, max-age=300'); } - /** - * Send a verification SMS code. - * - * @param \\Illuminate\Http\Request $request - * - * @return \Illuminate\Http\Response $response - */ - public function sendVerificationSms(Request $request) - { - // Users phone number - $phone = $queryPhone = $request->input('phone'); - $countryCode = $request->input('countryCode'); - $for = $request->input('driver'); - - // set phone number - if (!Str::startsWith($queryPhone, '+')) { - $queryPhone = '+' . $countryCode . $phone; - } - - // Make sure user exists with phone number - $userExistsQuery = User::where('phone', $queryPhone)->whereNull('deleted_at')->withoutGlobalScopes(); - - if ($for === 'driver') { - $userExistsQuery->where('type', 'driver'); - } - - $userExists = $userExistsQuery->exists(); - - if (!$userExists) { - return response()->error('No user with this phone # found.'); - } - - // Generate hto - $verifyCode = mt_rand(100000, 999999); - $verifyCodeKey = Str::slug($queryPhone . '_verify_code', '_'); - - // Send user their verification code - try { - Twilio::message($queryPhone, 'Your Fleetbase authentication code is ' . $verifyCode); - } catch (\Exception|\Twilio\Exceptions\RestException $e) { - return response()->json(['error' => $e->getMessage()], 400); - } - - // Store verify code for this number with a 10-minute TTL to prevent replay attacks - Redis::setex($verifyCodeKey, 600, $verifyCode); - - // 200 OK - return response()->json(['status' => 'OK']); - } - - /** - * Authenticate a user with SMS code. - * - * @param \\Illuminate\Http\Request $request - * - * @return \Illuminate\Http\Response $response - */ - public function authenticateSmsCode(Request $request) - { - // Users phone number - $phone = $queryPhone = $request->input('phone'); - $countryCode = $request->input('countryCode'); - - // set phone number - if (!Str::startsWith($queryPhone, '+')) { - $queryPhone = '+' . $countryCode . $phone; - } - - // Users verfiy code entered - $verifyCode = $request->input('code'); - $verifyCodeKey = Str::slug($queryPhone . '_verify_code', '_'); - - // Retrieve the stored verification code from Redis - $storedVerifyCode = Redis::get($verifyCodeKey); - - // Retrieve the optional testing bypass code from configuration. - // This is configurable via the SMS_AUTH_BYPASS_CODE environment variable - // and is intended for development/testing environments only. - // It MUST be left unset (null) in production deployments. - $bypassCode = config('fleetbase.sms_auth_bypass_code'); - - // Verify the submitted code against the stored OTP using a constant-time - // comparison to prevent timing attacks. If a bypass code is configured - // and the environment is not production, also allow that code. - $isValidOtp = !empty($storedVerifyCode) && hash_equals((string) $storedVerifyCode, (string) $verifyCode); - $isBypassValid = !empty($bypassCode) && !app()->environment('production') && hash_equals((string) $bypassCode, (string) $verifyCode); - - if (!$isValidOtp && !$isBypassValid) { - return response()->error('Invalid verification code'); - } - - // Remove from redis - Redis::del($verifyCodeKey); - - // get user for phone number - $user = User::where('phone', $queryPhone)->first(); - - // Attempt authentication - if ($user) { - // Set authenticatin user - Auth::login($user); - - // Generate token - try { - $token = $user->createToken($user->phone)->plainTextToken; - } catch (\Exception $e) { - return response()->error($e->getMessage()); - } - - // @codeCoverageIgnoreStart - // The driver relation is supplied by Fleet-Ops; core-api tests cannot load that package relation directly. - if ($user->type === 'driver') { - $user->load(['driver']); - } - // @codeCoverageIgnoreEnd - - // Send message to notify users authentication - return response()->json([ - 'token' => $token, - 'user' => $user, - ]); - } - - // If unable to authenticate user, respond with error - return response()->json('Authentication failed', 401); - } - /** * Create resend verification code session. * @@ -516,41 +388,6 @@ public function verifyEmail(Request $request) ]); } - /** - * Allow user to verify SMS code. - * - * @param \\Illuminate\Http\Request $request - * - * @return \Illuminate\Http\Response $response - */ - public function verifySmsCode(Request $request) - { - // Users phone number - $phone = $request->input('phone'); - - // Users verfiy code entered - $verifyCode = $request->input('code'); - $verifyCodeKey = Str::slug($phone . '_verify_code', '_'); - - // Generate hto - $storedVerifyCode = Redis::get($verifyCodeKey); - - // Verify - if ($verifyCode === $storedVerifyCode) { - // Remove from redis - Redis::del($verifyCodeKey); - - // 200 OK - return response()->json([ - 'status' => 'OK', - 'message' => 'Code verified', - ]); - } - - // 400 ERROR - return response()->error('Invalid verification code'); - } - /** * Creates a new company and user account. * diff --git a/src/Http/Controllers/Internal/v1/DatabaseBackupController.php b/src/Http/Controllers/Internal/v1/DatabaseBackupController.php new file mode 100644 index 00000000..4cf5bf68 --- /dev/null +++ b/src/Http/Controllers/Internal/v1/DatabaseBackupController.php @@ -0,0 +1,99 @@ +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..775ea427 100644 --- a/src/Http/Controllers/Internal/v1/SettingController.php +++ b/src/Http/Controllers/Internal/v1/SettingController.php @@ -589,7 +589,7 @@ public function testSmsProviderConfig(AdminRequest $request) $result = null; try { - $result = (new SmsService())->send($phone, $message, [], $provider); + $result = (new SmsService())->send($phone, $message, ['purpose' => 'admin_test'], $provider); if (is_array($result) && data_get($result, 'success') === false) { $status = 'error'; $responseMessage = data_get($result, 'error', 'SMS provider returned an error.'); @@ -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/Internal/v1/SmsGuardController.php b/src/Http/Controllers/Internal/v1/SmsGuardController.php new file mode 100644 index 00000000..2b1fcfd0 --- /dev/null +++ b/src/Http/Controllers/Internal/v1/SmsGuardController.php @@ -0,0 +1,300 @@ +json($this->settingsPayload(SmsPolicy::settings())); + } + + /** + * Save the administrator's policy; it applies within a minute on every worker. + */ + public function saveSettings(AdminRequest $request): JsonResponse + { + $validated = $request->validate([ + 'enabled' => ['required', 'boolean'], + 'mode' => ['required', Rule::in([SmsPolicy::MODE_ENFORCE, SmsPolicy::MODE_MONITOR])], + 'alert_emails' => ['sometimes', 'array'], + 'alert_emails.*' => ['email'], + 'validation' => ['sometimes', 'array'], + 'validation.enabled' => ['sometimes', 'boolean'], + 'validation.allowed_types' => ['sometimes', 'array'], + 'validation.allowed_types.*' => ['string', 'max:32'], + 'validation.blocked_countries' => ['sometimes', 'array'], + 'validation.blocked_countries.*' => ['string', 'size:2'], + 'validation.blocked_prefixes' => ['sometimes', 'array'], + 'validation.blocked_prefixes.*' => ['string', 'max:8'], + 'limits' => ['sometimes', 'array'], + 'limits.destination.cooldown_seconds' => ['nullable', 'integer', 'min:0', 'max:86400'], + 'limits.destination.per_hour' => ['nullable', 'integer', 'min:0', 'max:100000'], + 'limits.destination.per_day' => ['nullable', 'integer', 'min:0', 'max:100000'], + 'limits.ip.per_15_minutes' => ['nullable', 'integer', 'min:0', 'max:100000'], + 'limits.ip.per_day' => ['nullable', 'integer', 'min:0', 'max:100000'], + 'limits.company.per_minute' => ['nullable', 'integer', 'min:0', 'max:1000000'], + 'limits.company.per_hour' => ['nullable', 'integer', 'min:0', 'max:1000000'], + 'limits.company.per_day' => ['nullable', 'integer', 'min:0', 'max:1000000'], + 'limits.tenant.per_minute' => ['nullable', 'integer', 'min:0', 'max:1000000'], + 'limits.tenant.per_hour' => ['nullable', 'integer', 'min:0', 'max:1000000'], + 'limits.tenant.per_day' => ['nullable', 'integer', 'min:0', 'max:1000000'], + 'limits.probation.days' => ['nullable', 'integer', 'min:0', 'max:365'], + 'limits.probation.per_day' => ['nullable', 'integer', 'min:0', 'max:1000000'], + 'limits.platform.public.per_minute' => ['nullable', 'integer', 'min:0', 'max:10000000'], + 'limits.platform.public.per_hour' => ['nullable', 'integer', 'min:0', 'max:10000000'], + 'limits.platform.public.per_day' => ['nullable', 'integer', 'min:0', 'max:10000000'], + 'limits.platform.trusted.per_minute' => ['nullable', 'integer', 'min:0', 'max:10000000'], + 'limits.platform.trusted.per_hour' => ['nullable', 'integer', 'min:0', 'max:10000000'], + 'limits.platform.trusted.per_day' => ['nullable', 'integer', 'min:0', 'max:10000000'], + 'breaker' => ['sometimes', 'array'], + 'breaker.pause_minutes' => ['nullable', 'integer', 'min:1', 'max:10080'], + 'breaker.alert_percentages' => ['sometimes', 'array'], + 'breaker.alert_percentages.*' => ['integer', 'min:1', 'max:99'], + 'breaker.distinct_destinations_per_10m' => ['nullable', 'integer', 'min:0', 'max:100000'], + 'breaker.auto_pauses_before_manual' => ['nullable', 'integer', 'min:1', 'max:100'], + 'breaker.new_country_min_sends' => ['nullable', 'integer', 'min:0', 'max:100000'], + 'breaker.new_country_min_history' => ['nullable', 'integer', 'min:0', 'max:1000000'], + 'breaker.conversion_min_issued' => ['nullable', 'integer', 'min:0', 'max:100000'], + 'breaker.conversion_min_percent' => ['nullable', 'integer', 'min:0', 'max:100'], + 'overrides' => ['sometimes', 'array'], + 'overrides.*.company_uuid' => ['required', 'string', 'distinct', Rule::exists('companies', 'uuid')], + 'overrides.*.unlimited' => ['sometimes', 'boolean'], + 'overrides.*.per_minute' => ['nullable', 'integer', 'min:0', 'max:1000000'], + 'overrides.*.per_hour' => ['nullable', 'integer', 'min:0', 'max:1000000'], + 'overrides.*.per_day' => ['nullable', 'integer', 'min:0', 'max:1000000'], + 'overrides.*.allowed_countries' => ['sometimes', 'array'], + 'overrides.*.allowed_countries.*' => ['string', 'size:2'], + 'overrides.*.note' => ['nullable', 'string', 'max:255'], + ]); + + // Lists replace, maps merge: an override removed from the list is gone, while the + // pauses and anything not on the page survive the save. + $validated['overrides'] = array_values($validated['overrides'] ?? []); + $validated['alert_emails'] = array_values($validated['alert_emails'] ?? []); + + return response()->json($this->settingsPayload(SmsPolicy::update($validated))); + } + + /** + * Discard the administrator's policy and pauses, back to the environment defaults. + */ + public function resetSettings(AdminRequest $request): JsonResponse + { + return response()->json($this->settingsPayload(SmsPolicy::reset())); + } + + /** + * The pauses in force. + */ + public function pauses(AdminRequest $request): JsonResponse + { + return response()->json(['pauses' => $this->pausesPayload()]); + } + + /** + * Pause a company, a store or network, or the platform. + */ + public function pause(AdminRequest $request): JsonResponse + { + $validated = $request->validate([ + 'scope' => ['required', 'string', 'max:120'], + 'minutes' => ['nullable', 'integer', 'min:1', 'max:525600'], + 'reason' => ['nullable', 'string', 'max:255'], + ]); + + $scope = SmsBreaker::resolveScope($validated['scope']); + if ($scope === null) { + return response()->error('No organization matches that scope.', 422); + } + + SmsGuard::make()->breaker()->pause( + $scope, + isset($validated['minutes']) ? (int) $validated['minutes'] : null, + (string) ($validated['reason'] ?? 'manual'), + false, + SmsBreaker::companyUuidFromScope($scope) + ); + + return response()->json(['pauses' => $this->pausesPayload()]); + } + + /** + * Lift a pause. + */ + public function unpause(AdminRequest $request, string $scope): JsonResponse + { + $resolved = SmsBreaker::resolveScope(urldecode($scope)); + if ($resolved === null) { + return response()->error('No organization matches that scope.', 422); + } + + SmsGuard::make()->breaker()->unpause($resolved); + + return response()->json(['pauses' => $this->pausesPayload()]); + } + + /** + * Sends per organization over a recent window, against their budgets. + */ + public function usage(AdminRequest $request): JsonResponse + { + $request->validate([ + 'hours' => ['sometimes', 'integer', 'min:1', 'max:720'], + 'limit' => ['sometimes', 'integer', 'min:1', 'max:200'], + ]); + + $hours = (int) $request->input('hours', 24); + $limit = (int) $request->input('limit', 50); + $settings = SmsPolicy::settings(); + $since = Carbon::now()->subHours($hours); + + $rows = SmsSend::query() + ->selectRaw('company_uuid, ' + . 'sum(case when status = ? then 1 else 0 end) as sent, ' + . 'sum(case when status = ? then 1 else 0 end) as failed, ' + . 'sum(case when status = ? then 1 else 0 end) as blocked, ' + . 'count(distinct destination_hash) as destinations, ' + . 'max(created_at) as last_send_at', [SmsSend::STATUS_SENT, SmsSend::STATUS_FAILED, SmsSend::STATUS_BLOCKED]) + ->where('created_at', '>=', $since) + ->whereNotNull('company_uuid') + ->groupBy('company_uuid') + ->orderByDesc('sent') + ->limit($limit) + ->get(); + + $companies = Company::whereIn('uuid', $rows->pluck('company_uuid')->all())->get(['uuid', 'public_id', 'name'])->keyBy('uuid'); + + $usage = $rows->map(function ($row) use ($companies, $settings) { + $company = $companies->get($row->company_uuid); + $override = SmsPolicy::overrideFor($row->company_uuid, $settings); + $budget = $override && $override['unlimited'] ? null : ($override['per_day'] ?? $settings['limits']['company']['per_day']); + + return [ + 'company_uuid' => $row->company_uuid, + 'company_id' => $company?->public_id, + 'company_name' => $company?->name, + 'sent' => (int) $row->sent, + 'failed' => (int) $row->failed, + 'blocked' => (int) $row->blocked, + 'destinations' => (int) $row->destinations, + 'last_send_at' => $row->last_send_at, + 'daily_budget' => $budget, + 'paused' => SmsGuard::make()->breaker()->pausedUntil('company:' . $row->company_uuid) !== null, + ]; + })->values(); + + return response()->json([ + 'hours' => $hours, + 'ledger_available' => SmsLedger::available(), + 'usage' => $usage, + ]); + } + + /** + * Recent ledger rows, newest first, filtered by organization, status, purpose or number. + */ + public function ledger(AdminRequest $request): JsonResponse + { + $request->validate([ + 'company_uuid' => ['sometimes', 'string', 'max:36'], + 'status' => ['sometimes', Rule::in([SmsSend::STATUS_RESERVED, SmsSend::STATUS_SENT, SmsSend::STATUS_FAILED, SmsSend::STATUS_BLOCKED])], + 'purpose' => ['sometimes', 'string', 'max:64'], + 'destination' => ['sometimes', 'string', 'max:32'], + 'hours' => ['sometimes', 'integer', 'min:1', 'max:2160'], + 'limit' => ['sometimes', 'integer', 'min:1', 'max:500'], + ]); + + $query = SmsSend::query() + ->where('created_at', '>=', Carbon::now()->subHours((int) $request->input('hours', 24))) + ->orderByDesc('created_at') + ->limit((int) $request->input('limit', 100)); + + if ($request->filled('company_uuid')) { + $query->where('company_uuid', $request->input('company_uuid')); + } + if ($request->filled('status')) { + $query->where('status', $request->input('status')); + } + if ($request->filled('purpose')) { + $query->where('purpose', $request->input('purpose')); + } + if ($request->filled('destination')) { + $query->where('destination_hash', SmsLedger::hash((string) $request->input('destination'))); + } + + return response()->json([ + 'ledger_available' => SmsLedger::available(), + 'sends' => $query->get(), + ]); + } + + protected function settingsPayload(array $settings): array + { + $companies = Company::whereIn('uuid', array_column($settings['overrides'], 'company_uuid')) + ->get(['uuid', 'public_id', 'name']) + ->keyBy('uuid'); + + $settings['overrides'] = array_map(function (array $override) use ($companies) { + $company = $companies->get($override['company_uuid']); + + return array_merge($override, [ + 'company_id' => $company?->public_id, + 'company_name' => $company?->name, + ]); + }, $settings['overrides']); + + return [ + 'settings' => $settings, + 'defaults' => SmsPolicy::defaults(), + 'pauses' => $this->pausesPayload(), + 'ledger_available' => SmsLedger::available(), + ]; + } + + /** + * @return array> + */ + protected function pausesPayload(): array + { + $pauses = collect(SmsGuard::make()->breaker()->pauses())->map(function (array $pause, string $scope) { + $pause['company_uuid'] = $pause['company_uuid'] ?: SmsBreaker::companyUuidFromScope($scope); + + return $pause; + }); + $uuids = $pauses->pluck('company_uuid')->filter()->unique()->values()->all(); + $names = $uuids ? Company::whereIn('uuid', $uuids)->get(['uuid', 'public_id', 'name'])->keyBy('uuid') : collect(); + + return $pauses->map(function (array $pause, string $scope) use ($names) { + $company = $pause['company_uuid'] ? $names->get($pause['company_uuid']) : null; + + return array_merge($pause, [ + 'scope' => $scope, + 'platform' => $scope === SmsBreaker::SCOPE_PLATFORM, + 'company_id' => $company?->public_id, + 'company_name' => $company?->name, + 'until_iso' => $pause['until'] ? Carbon::createFromTimestamp($pause['until'])->toIso8601String() : null, + ]); + })->values()->all(); + } +} diff --git a/src/Http/Controllers/Internal/v1/UserController.php b/src/Http/Controllers/Internal/v1/UserController.php index df4f6089..abee4653 100644 --- a/src/Http/Controllers/Internal/v1/UserController.php +++ b/src/Http/Controllers/Internal/v1/UserController.php @@ -1355,7 +1355,7 @@ public function sendVerification(Request $request, string $id) try { if ($channel === 'phone') { $url = UserContactVerificationRequested::urlFor($verificationCode); - $result = app(SmsService::class)->send($value, 'Verify your ' . config('app.name') . ' phone number: ' . $url); + $result = app(SmsService::class)->send($value, 'Verify your ' . config('app.name') . ' phone number: ' . $url, ['purpose' => 'admin_user_verification']); if (is_array($result) && array_key_exists('success', $result) && !$result['success']) { throw new \RuntimeException((string) ($result['error'] ?? $result['message'] ?? 'The SMS provider refused the message.')); } diff --git a/src/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/Filter/CommentFilter.php b/src/Http/Filter/CommentFilter.php index da327424..153396e6 100644 --- a/src/Http/Filter/CommentFilter.php +++ b/src/Http/Filter/CommentFilter.php @@ -15,6 +15,9 @@ public function queryForInternal() public function queryForPublic() { $this->queryForInternal(); + + // Internal notes (`meta.visibility = internal`) never leave the console. + $this->builder->visibleToCustomers(); } public function subject(string $id) diff --git a/src/Http/Filter/ReportFilter.php b/src/Http/Filter/ReportFilter.php new file mode 100644 index 00000000..00a37406 --- /dev/null +++ b/src/Http/Filter/ReportFilter.php @@ -0,0 +1,16 @@ +builder->where($this->builder->qualifyColumn('company_uuid'), $this->session->get('company')); + } + + public function queryForPublic() + { + $this->queryForInternal(); + } +} diff --git a/src/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/LogApiRequests.php b/src/Http/Middleware/LogApiRequests.php index 7405ecf7..9e2714e5 100644 --- a/src/Http/Middleware/LogApiRequests.php +++ b/src/Http/Middleware/LogApiRequests.php @@ -59,7 +59,10 @@ public function handle($request, \Closure $next) */ protected function shouldLog($request): bool { - if (!Http::isPublicRequest($request)) { + // Every route that runs this middleware opted in to logging through its + // middleware group (core's public API or an extension's), so only skip + // requests without a route and internal routes such as "int/v1/..." + if ($request->route() === null || Http::isInternalRequest($request)) { return false; } diff --git a/src/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/Http/Requests/CreateCommentRequest.php b/src/Http/Requests/CreateCommentRequest.php index 6d16696e..00497754 100644 --- a/src/Http/Requests/CreateCommentRequest.php +++ b/src/Http/Requests/CreateCommentRequest.php @@ -2,6 +2,7 @@ namespace Fleetbase\Http\Requests; +use Fleetbase\Models\Comment; use Illuminate\Validation\Rule; class CreateCommentRequest extends FleetbaseRequest @@ -43,6 +44,7 @@ public function rules() return !$this->filled('parent') && !$this->filled('subject') && !$this->filled('subject_type') && !$this->filled('subject_id') && !$this->filled('subject_uuid') && $this->isMethod('POST'); })], 'content' => ['required'], + 'visibility' => ['nullable', Rule::in(Comment::VISIBILITIES)], ]; } } diff --git a/src/Http/Resources/ChatMessage.php b/src/Http/Resources/ChatMessage.php index da8cee77..e433c3e9 100644 --- a/src/Http/Resources/ChatMessage.php +++ b/src/Http/Resources/ChatMessage.php @@ -3,6 +3,7 @@ namespace Fleetbase\Http\Resources; use Fleetbase\Support\Http; +use Fleetbase\Support\Utils; class ChatMessage extends FleetbaseResource { @@ -21,9 +22,10 @@ public function toArray($request) 'chat_channel_uuid' => $this->when(Http::isInternalRequest(), $this->chat_channel_uuid), 'chat_channel' => $this->when(Http::isPublicRequest(), $this->chatChannel->public_id), 'sender_uuid' => $this->when(Http::isInternalRequest(), $this->sender_uuid), - 'sender' => $this->when(Http::isPublicRequest(), $this->sender->public_id), 'content' => $this->content, - 'sender' => new ChatParticipant($this->sender), + 'sender' => $this->sender ? new ChatParticipant($this->sender) : null, + 'system' => method_exists($this->resource, 'isSystem') && $this->resource->isSystem(), + 'meta' => data_get($this, 'meta') ?: Utils::createObject(), 'attachments' => ChatAttachment::collection($this->attachments), 'receipts' => ChatReceipt::collection($this->receipts), 'updated_at' => $this->updated_at, diff --git a/src/Http/Resources/ChatParticipant.php b/src/Http/Resources/ChatParticipant.php index 5ad1a912..6ae8ced0 100644 --- a/src/Http/Resources/ChatParticipant.php +++ b/src/Http/Resources/ChatParticipant.php @@ -3,6 +3,7 @@ namespace Fleetbase\Http\Resources; use Fleetbase\Support\Http; +use Fleetbase\Support\Utils; class ChatParticipant extends FleetbaseResource { @@ -27,6 +28,8 @@ public function toArray($request) 'email' => $this->whenLoaded('user', fn () => $this->user->email), 'phone' => $this->whenLoaded('user', fn () => $this->user->phone), 'avatar_url' => $this->whenLoaded('user', fn () => $this->user->avatar_url), + 'role' => method_exists($this->resource, 'getRole') ? $this->resource->getRole() : null, + 'meta' => data_get($this, 'meta') ?: Utils::createObject(), 'is_online' => $this->is_online, 'last_seen_at' => $this->last_seen_at, 'updated_at' => $this->updated_at, diff --git a/src/Http/Resources/Comment.php b/src/Http/Resources/Comment.php index 0facfa62..8db9e54e 100644 --- a/src/Http/Resources/Comment.php +++ b/src/Http/Resources/Comment.php @@ -28,12 +28,26 @@ public function toArray($request) 'content' => $this->content, 'tags' => $this->tags, 'meta' => data_get($this, 'meta', Utils::createObject()), + 'visibility' => method_exists($this->resource, 'getVisibility') ? $this->resource->getVisibility() : null, 'author' => new Author($this->author), - 'replies' => static::collection($this->replies), + 'replies' => static::collection($this->visibleReplies()), 'editable' => $request->hasSession() && session('user') === $this->author_uuid, 'updated_at' => $this->updated_at, 'created_at' => $this->created_at, 'deleted_at' => $this->deleted_at, ]; } + + /** + * Replies to show: all of them for the console, only customer visible ones anywhere else. + */ + protected function visibleReplies(): mixed + { + $replies = $this->replies; + if (!$replies || Http::isInternalRequest()) { + return $replies; + } + + return $replies->filter(fn ($reply) => !method_exists($reply, 'isVisibleToCustomers') || $reply->isVisibleToCustomers())->values(); + } } diff --git a/src/Jobs/LogApiRequest.php b/src/Jobs/LogApiRequest.php index 2699eabf..5049d13f 100644 --- a/src/Jobs/LogApiRequest.php +++ b/src/Jobs/LogApiRequest.php @@ -25,6 +25,16 @@ class LogApiRequest implements ShouldQueue use Queueable; use SerializesModels; + /** + * Request attribute holding ids to add to the log's related column. + * + * Extensions set it from their middleware so a logged request can be traced + * to the resource behind it, for example the owner of the key that made it. + * + * @var string + */ + public const RELATED_ATTRIBUTE = 'api_request_log_related'; + /** * The API Request/JsonResponse payload. * @@ -82,6 +92,13 @@ public static function getPayload(Request $request, $response): array $related[] = Utils::get($content, 'id'); } + // Push ids that middleware related to this request, such as the resource whose key made it + foreach ((array) $request->attributes->get(static::RELATED_ATTRIBUTE, []) as $id) { + if ($id) { + $related[] = $id; + } + } + // Get api credential from session $apiCredential = session('api_credential'); 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/ChatMessage.php b/src/Models/ChatMessage.php index e2eafc1b..ffca18f7 100644 --- a/src/Models/ChatMessage.php +++ b/src/Models/ChatMessage.php @@ -2,6 +2,7 @@ namespace Fleetbase\Models; +use Fleetbase\Casts\Json; use Fleetbase\Notifications\ChatMessageReceived; use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasPublicId; @@ -34,7 +35,7 @@ class ChatMessage extends Model * * @var array */ - protected $fillable = ['company_uuid', 'chat_channel_uuid', 'sender_uuid', 'content']; + protected $fillable = ['company_uuid', 'chat_channel_uuid', 'sender_uuid', 'content', 'meta']; /** * The relationships to always load along with the model. @@ -43,6 +44,15 @@ class ChatMessage extends Model */ protected $with = ['sender', 'attachments', 'receipts']; + /** + * The attributes that should be cast to native types. + * + * @var array + */ + protected $casts = [ + 'meta' => Json::class, + ]; + /** * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ @@ -75,6 +85,24 @@ public function receipts() return $this->hasMany(ChatReceipt::class, 'chat_message_uuid', 'uuid')->whereHas('participant'); } + /** + * Whether this is a system message (`meta.system = true`), e.g. "order updated". + */ + public function isSystem(): bool + { + return (bool) data_get($this->meta, 'system', false); + } + + /** + * The system message kind (`meta.kind`), e.g. `order_updated` or `address_change_requested`. + */ + public function getSystemKind(): ?string + { + $kind = $this->isSystem() ? data_get($this->meta, 'kind') : null; + + return is_string($kind) ? $kind : null; + } + /** * Notify all participants in the chat channel (excluding the sender) of a new chat message. * diff --git a/src/Models/ChatParticipant.php b/src/Models/ChatParticipant.php index 985168c3..cb9c8638 100644 --- a/src/Models/ChatParticipant.php +++ b/src/Models/ChatParticipant.php @@ -2,6 +2,7 @@ namespace Fleetbase\Models; +use Fleetbase\Casts\Json; use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasPublicId; use Fleetbase\Traits\HasUuid; @@ -17,6 +18,15 @@ class ChatParticipant extends Model use HasApiModelBehavior; use SendsWebhooks; + /** + * Participant roles (`meta.role`), used by order chats to tell apps who is speaking. + */ + public const ROLE_CUSTOMER = 'customer'; + public const ROLE_DRIVER = 'driver'; + public const ROLE_STORE = 'store'; + public const ROLE_SYSTEM = 'system'; + public const ROLES = [self::ROLE_CUSTOMER, self::ROLE_DRIVER, self::ROLE_STORE, self::ROLE_SYSTEM]; + /** * The database table used by the model. * @@ -36,7 +46,7 @@ class ChatParticipant extends Model * * @var array */ - protected $fillable = ['company_uuid', 'chat_channel_uuid', 'user_uuid']; + protected $fillable = ['company_uuid', 'chat_channel_uuid', 'user_uuid', 'meta']; /** * Dynamic attributes that are appended to object. @@ -57,7 +67,9 @@ class ChatParticipant extends Model * * @var array */ - protected $casts = []; + protected $casts = [ + 'meta' => Json::class, + ]; public function user(): BelongsTo|Builder { @@ -107,6 +119,16 @@ public function getIsOnlineAttribute(): bool return $this->user ? $this->user->isOnline() : false; } + /** + * The participant's role (`meta.role`) when it is one of the known roles. + */ + public function getRole(): ?string + { + $role = data_get($this->meta, 'role'); + + return in_array($role, self::ROLES, true) ? $role : null; + } + /** * Get the chat participant's user name. */ diff --git a/src/Models/Comment.php b/src/Models/Comment.php index c04ede11..1c98a0c3 100644 --- a/src/Models/Comment.php +++ b/src/Models/Comment.php @@ -4,9 +4,15 @@ use Fleetbase\Casts\Json; use Fleetbase\Casts\PolymorphicType; +use Fleetbase\Events\CommentCreated; +use Fleetbase\Support\Http; use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasPublicId; use Fleetbase\Traits\HasUuid; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; class Comment extends Model { @@ -14,6 +20,30 @@ class Comment extends Model use HasPublicId; use HasApiModelBehavior; + /** + * Visible to the company's own users only (console notes). + */ + public const VISIBILITY_INTERNAL = 'internal'; + + /** + * Visible to the customer as well (customer facing APIs and channels). + */ + public const VISIBILITY_CUSTOMER = 'customer'; + + /** + * The values `meta.visibility` accepts. + */ + public const VISIBILITIES = [self::VISIBILITY_INTERNAL, self::VISIBILITY_CUSTOMER]; + + /** + * Subject types (snake cased class basenames) whose comments default to `internal` when they are + * written through the internal (console) API, and to `customer` otherwise. Comments on any other + * subject keep no visibility unless one is given, which is treated as visible. + * + * @var array + */ + public static array $visibilityAwareSubjectTypes = ['order']; + /** * The database table used by the model. * @@ -74,6 +104,118 @@ class Comment extends Model 'subject_type' => PolymorphicType::class, ]; + /** + * Default the comment visibility and broadcast new comments. + */ + protected static function booted(): void + { + static::creating(function (Comment $comment) { + $comment->applyDefaultVisibility(); + }); + + static::created(function (Comment $comment) { + // A broadcast failure (socket server down, missing broadcaster) must never fail the comment. + try { + Event::dispatch(new CommentCreated($comment)); + } catch (\Throwable $e) { + Log::warning('Unable to broadcast comment.created', ['comment' => $comment->public_id, 'error' => $e->getMessage()]); + } + }); + } + + /** + * Only comments a customer may see: everything except `internal` ones. Comments with no + * visibility (written before visibility existed, or on subjects without a default) stay visible. + */ + public function scopeVisibleToCustomers(Builder $query): Builder + { + return $query->where(function (Builder $query) { + $query->whereNull('meta') + ->orWhereNull('meta->visibility') + ->orWhere('meta->visibility', '!=', self::VISIBILITY_INTERNAL); + }); + } + + /** + * Only `internal` comments. + */ + public function scopeInternal(Builder $query): Builder + { + return $query->where('meta->visibility', self::VISIBILITY_INTERNAL); + } + + /** + * The effective visibility: `internal` when marked so, `customer` otherwise. + */ + public function getVisibility(): string + { + return data_get($this->meta, 'visibility') === self::VISIBILITY_INTERNAL ? self::VISIBILITY_INTERNAL : self::VISIBILITY_CUSTOMER; + } + + public function isInternal(): bool + { + return $this->getVisibility() === self::VISIBILITY_INTERNAL; + } + + public function isVisibleToCustomers(): bool + { + return !$this->isInternal(); + } + + /** + * Whether a subject type (class name or short type) defaults its comments' visibility. + */ + public static function isVisibilityAwareSubjectType(?string $subjectType): bool + { + if (!$subjectType) { + return false; + } + + return in_array(Str::snake(class_basename(ltrim($subjectType, '\\'))), static::$visibilityAwareSubjectTypes, true); + } + + /** + * Fills `meta.visibility` when it is missing or not a known value: replies follow their parent, + * comments on visibility aware subjects (orders) are `internal` from the console and `customer` + * from everywhere else, and other comments are left without one (visible). + */ + public function applyDefaultVisibility(): void + { + $meta = is_array($this->meta) ? $this->meta : []; + $visibility = $meta['visibility'] ?? null; + + if (in_array($visibility, self::VISIBILITIES, true)) { + return; + } + + $default = null; + if ($this->parent_comment_uuid) { + $parentMeta = static::query() + ->withoutGlobalScopes() + ->where('uuid', $this->parent_comment_uuid) + ->value('meta'); + $default = data_get(Json::decode($parentMeta), 'visibility'); + } + + if (!in_array($default, self::VISIBILITIES, true)) { + $default = static::isVisibilityAwareSubjectType($this->subject_type) + ? (Http::isInternalRequest() ? self::VISIBILITY_INTERNAL : self::VISIBILITY_CUSTOMER) + : null; + } + + if ($default === null && $visibility === null) { + // Nothing to set: leave meta exactly as given (a null column stays null). + return; + } + + unset($meta['visibility']); + if ($default !== null) { + $meta['visibility'] = $default; + } + + $this->meta = $meta; + } + /** * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ @@ -119,11 +261,23 @@ public function replies() */ public static function publish(array $attributes): Comment { - static::unguard(); - // If replies or parent is set somehow unset it unset($attributes['replies'], $attributes['parent']); + // `visibility` is accepted as a shorthand for `meta.visibility`, it is not a column. + if (array_key_exists('visibility', $attributes)) { + $visibility = $attributes['visibility']; + unset($attributes['visibility']); + + if (in_array($visibility, self::VISIBILITIES, true)) { + $meta = $attributes['meta'] ?? []; + $meta = is_string($meta) ? (json_decode($meta, true) ?: []) : (array) $meta; + + $meta['visibility'] = $visibility; + $attributes['meta'] = $meta; + } + } + // set author if unset if (empty($attributes['author_uuid'])) { $attributes['author_uuid'] = session('user'); @@ -139,9 +293,10 @@ public static function publish(array $attributes): Comment $attributes['created_at'] = now(); $attributes['updated_at'] = now(); - $comment = static::create($attributes); - static::reguard(); - - return $comment; + // Unguard only for this insert: `unguarded()` restores the guard even when the insert + // throws. A bare unguard() left every model in a long-lived (Octane) worker unguarded + // after a failed insert, so request params became where clauses and stray payload keys + // became insert columns on unrelated models. + return static::unguarded(fn () => static::create($attributes)); } } 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/SmsSend.php b/src/Models/SmsSend.php new file mode 100644 index 00000000..8b2e2a06 --- /dev/null +++ b/src/Models/SmsSend.php @@ -0,0 +1,69 @@ + Json::class, + ]; + + public function getConnectionName() + { + return $this->connection ?: config('fleetbase.connection.db', 'mysql'); + } + + protected static function booted(): void + { + static::creating(function (self $send) { + if (blank($send->uuid)) { + $send->uuid = (string) Str::uuid(); + } + }); + } +} diff --git a/src/Models/TemplateQuery.php b/src/Models/TemplateQuery.php index 6c1641b9..225de5c3 100644 --- a/src/Models/TemplateQuery.php +++ b/src/Models/TemplateQuery.php @@ -4,6 +4,7 @@ use Fleetbase\Casts\Json; use Fleetbase\Services\TemplateRenderService; +use Fleetbase\Support\RequestRelations; use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasPublicId; use Fleetbase\Traits\HasUuid; @@ -180,7 +181,8 @@ public function execute(): \Illuminate\Support\Collection } // Eager-load relationships - $with = $this->getAttribute('with'); + // The stored list is user-editable, so it is held to the same relation check as a request's `with` + $with = RequestRelations::eagerLoads($model, $this->getAttribute('with')); if (!empty($with)) { $query->with($with); } diff --git a/src/Models/VerificationCode.php b/src/Models/VerificationCode.php index c7b45b63..d31550c7 100644 --- a/src/Models/VerificationCode.php +++ b/src/Models/VerificationCode.php @@ -5,6 +5,10 @@ use Fleetbase\Casts\Json; use Fleetbase\Mail\VerificationMail; use Fleetbase\Services\SmsService; +use Fleetbase\Support\Sms\SmsContext; +use Fleetbase\Support\Sms\SmsGuard; +use Fleetbase\Support\Sms\SmsLedger; +use Fleetbase\Support\Sms\SmsReservation; use Fleetbase\Support\Utils; use Fleetbase\Traits\Expirable; use Fleetbase\Traits\HasMetaAttributes; @@ -13,6 +17,7 @@ use Fleetbase\Twilio\Support\Laravel\Facade as Twilio; use Illuminate\Support\Arr; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Mail; class VerificationCode extends Model @@ -34,7 +39,7 @@ class VerificationCode extends Model * * @var array */ - protected $fillable = ['subject_uuid', 'subject_type', 'code', 'for', 'expires_at', 'meta', 'status']; + protected $fillable = ['subject_uuid', 'subject_type', 'code', 'for', 'expires_at', 'meta', 'status', 'company_uuid', 'identity', 'channel', 'ip_address', 'user_agent', 'sms_send_uuid', 'consumed_at']; /** * The attributes that should be cast to native types. @@ -42,10 +47,14 @@ class VerificationCode extends Model * @var array */ protected $casts = [ - 'expires_at' => 'datetime', - 'meta' => Json::class, + 'expires_at' => 'datetime', + 'consumed_at' => 'datetime', + 'meta' => Json::class, ]; + public const STATUS_USED = 'used'; + public const STATUS_LOCKED = 'locked'; + /** * Dynamic attributes that are appended to object. * @@ -60,15 +69,218 @@ 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)); + } + + /** + * Mark a code used so it cannot verify again. The row stays, for the ledger. + */ + public function consume(): void + { + $this->status = self::STATUS_USED; + if (static::supportsTracingColumns()) { + $this->consumed_at = Carbon::now(); + } + $this->setMeta('consumed_at', Carbon::now()->toIso8601String()); + $this->save(); + } + + /** + * The newest live code filed under an identity for a purpose: not expired (the default + * scope), not used, not locked. Looks at the `identity` column where the table has it + * and at `meta->identity` otherwise, so rows written before the column exists resolve. + */ + public static function findActiveFor(string $for, string $identity, ?string $subjectUuid = null): ?static + { + $query = static::query()->where('for', $for); + + if (static::supportsTracingColumns()) { + $query->where(function ($query) use ($identity) { + $query->where('identity', $identity)->orWhere('meta->identity', $identity); + }); + } else { + $query->where('meta->identity', $identity); + } + + if ($subjectUuid) { + $query->where('subject_uuid', $subjectUuid); + } + + return $query + ->where(function ($query) { + $query->whereNull('status')->orWhereNotIn('status', [self::STATUS_USED, self::STATUS_LOCKED]); + }) + ->orderByDesc('created_at') + ->first(); + } + + /** + * The newest live code for a subject and purpose. + */ + public static function findActiveForSubject(string $for, string $subjectUuid): ?static + { + return static::query() + ->where('for', $for) + ->where('subject_uuid', $subjectUuid) + ->where(function ($query) { + $query->whereNull('status')->orWhereNotIn('status', [self::STATUS_USED, self::STATUS_LOCKED]); + }) + ->orderByDesc('created_at') + ->first(); + } + + /** + * Whether the table has the tracing columns added in October 2026 (company, identity, + * channel, address, ledger link, consumed_at). An install that has not migrated yet, + * and a test schema, keep working with `meta` alone. Remembered in the cache for five + * minutes, so a migration is picked up without a restart. + */ + public static function supportsTracingColumns(): bool + { + $model = new static(); + $check = function () use ($model): bool { + try { + return (bool) $model->getConnection()->getSchemaBuilder()->hasColumn($model->getTable(), 'identity'); + } catch (\Throwable $e) { + return false; + } + }; + + try { + return (bool) Cache::remember('verification_codes:tracing_columns:' . ($model->getConnectionName() ?: 'default'), 300, $check); + } catch (\Throwable $e) { + return $check(); + } + } + + /** + * What a code row keeps about who asked for it, set only where the table has the columns. + */ + protected static function tracingAttributes(?string $companyUuid, string $channel, ?string $identity, ?SmsReservation $reservation = null): array + { + if (!static::supportsTracingColumns()) { + return []; + } + + $request = null; + try { + $request = request(); + } catch (\Throwable $e) { + // no request: a console or queued send + } + + return [ + 'company_uuid' => $companyUuid ?: null, + 'identity' => $identity ?: null, + 'channel' => $channel, + 'ip_address' => $reservation?->context->ip ?? ($request ? $request->ip() : null), + 'user_agent' => $reservation?->context->userAgent ?? ($request && $request->userAgent() ? substr((string) $request->userAgent(), 0, 250) : null), + 'sms_send_uuid' => $reservation?->sendUuid, + ]; + } + /** * @return \Illuminate\Database\Eloquent\Relations\MorphTo */ @@ -120,10 +332,16 @@ public static function generateFor($subject = null, $for = 'general_verification public static function generateEmailVerificationFor($subject, $for = 'email_verification', array $options = []) { $expireAfter = data_get($options, 'expireAfter'); + $meta = (array) data_get($options, 'meta', []); $verificationCode = static::generateFor($subject, $for, false); $verificationCode->expires_at = $expireAfter === null ? Carbon::now()->addHour() : $expireAfter; - $verificationCode->meta = data_get($options, 'meta', []); + $verificationCode->meta = $meta; $verificationCode->status = data_get($options, 'status', 'active'); + $verificationCode->forceFill(static::tracingAttributes( + data_get($options, 'company_uuid') ?? session('company') ?? data_get($subject, 'company_uuid'), + 'email', + (string) (data_get($meta, 'identity') ?? data_get($subject, 'email') ?? '') + )); $verificationCode->save(); if (isset($subject->email)) { @@ -167,28 +385,76 @@ public static function generateEmailVerificationFor($subject, $for = 'email_veri * * @return static returns the verification code instance after persisting it to the database and sending the SMS * - * @throws \Exception throws an exception if the SMS cannot be sent + * @throws \Fleetbase\Exceptions\SmsBlockedException when the SMS guard refuses the send; no code row is written + * @throws \Exception throws an exception if the SMS cannot be sent */ public static function generateSmsVerificationFor($subject, $for = 'phone_verification', array $options = []) { - $expireAfter = data_get($options, 'expireAfter'); + $expireAfter = data_get($options, 'expireAfter'); + $destination = data_get($subject, 'phone'); + $companyUuid = data_get($options, 'company_uuid') ?? session('company') ?? data_get($subject, 'company_uuid'); + + // Ask the guard BEFORE the code row exists: a refused send must leave nothing + // behind, and the budgets must be counted even when the provider later fails. + $reservation = null; + if ($destination) { + $reservation = SmsGuard::make()->reserve(SmsContext::resolve((string) $destination, [ + 'purpose' => $for, + 'company_uuid' => $companyUuid, + 'source' => data_get($options, 'source'), + 'subject' => $subject, + ])); + } + + $meta = (array) data_get($options, 'meta', []); + if ($reservation && $reservation->isGuarded()) { + // Who asked for this code and from where, so an incident traces from the table. + $meta['trace'] = $reservation->trace(); + } + + // With `hashed`, the row keeps an HMAC of the code (as issue() does) and the plain + // code exists only in the message and on the returned instance's `plainCode`. + $plainCode = null; + if (data_get($options, 'hashed')) { + $plainCode = (string) random_int(100000, 999999); + $meta['hashed'] = true; + $meta['attempts'] = 0; + } + + $identity = (string) (data_get($meta, 'identity') ?? data_get($meta, 'phone') ?? $destination ?? ''); $verificationCode = static::generateFor($subject, $for, false); - $verificationCode->expires_at = $expireAfter === null ? Carbon::now()->addHour() : $expireAfter; - $verificationCode->meta = data_get($options, 'meta', []); + $verificationCode->expires_at = $expireAfter === null ? ($plainCode !== null ? Carbon::now()->addMinutes(10) : Carbon::now()->addHour()) : $expireAfter; + $verificationCode->meta = $meta; + $verificationCode->status = data_get($options, 'status', $plainCode !== null ? 'active' : $verificationCode->status); + if ($plainCode !== null) { + $verificationCode->code = static::hashCode($plainCode); + } + $verificationCode->forceFill(static::tracingAttributes($companyUuid, 'sms', $identity, $reservation)); $verificationCode->save(); + $verificationCode->plainCode = $plainCode; + + if ($reservation && $reservation->sendUuid) { + SmsLedger::update($reservation->sendUuid, ['verification_code_uuid' => $verificationCode->uuid]); + } - // Get message - $message = 'Your ' . config('app.name') . ' verification code is ' . $verificationCode->code; + // Get message. A message callback reads `->code` off the instance it is given; for + // a hashed code it gets a copy carrying the plain code, never the stored HMAC. + $forMessage = $verificationCode; + if ($plainCode !== null) { + $forMessage = clone $verificationCode; + $forMessage->code = $plainCode; + } + + $message = 'Your ' . config('app.name') . ' verification code is ' . $forMessage->code; $messageCallback = data_get($options, 'messageCallback'); if (is_callable($messageCallback)) { - $message = $messageCallback($verificationCode); + $message = $messageCallback($forMessage); } - // SMS service options - $smsOptions = []; + // SMS service options, including the reservation so the send is counted once + $smsOptions = ['sms_reservation' => $reservation]; // Check for company-specific sender ID - $companyUuid = data_get($options, 'company_uuid') ?? session('company') ?? data_get($subject, 'company_uuid'); if ($companyUuid) { $company = Company::select(['uuid', 'options'])->find($companyUuid); diff --git a/src/Notifications/Channels/SmsServiceChannel.php b/src/Notifications/Channels/SmsServiceChannel.php new file mode 100644 index 00000000..cb4b744d --- /dev/null +++ b/src/Notifications/Channels/SmsServiceChannel.php @@ -0,0 +1,61 @@ +destinationFor($notifiable, $notification); + if (blank($to)) { + return; + } + + $message = method_exists($notification, 'toSms') ? $notification->toSms($notifiable) : null; + $text = is_object($message) ? (string) ($message->content ?? '') : (string) $message; + if ($text === '') { + return; + } + + $options = [ + 'purpose' => $notification->smsPurpose ?? 'notification:' . class_basename($notification), + 'company_uuid' => $notification->smsCompanyUuid ?? data_get($notifiable, 'company_uuid'), + ]; + + ($this->service ?? app(SmsService::class))->send((string) $to, $text, $options); + } + + protected function destinationFor($notifiable, Notification $notification): ?string + { + if (is_object($notifiable) && method_exists($notifiable, 'routeNotificationFor')) { + foreach (['sms', 'twilio'] as $driver) { + $to = $notifiable->routeNotificationFor($driver, $notification); + if (filled($to)) { + return (string) $to; + } + } + } + + $phone = data_get($notifiable, 'phone'); + + return filled($phone) ? (string) $phone : null; + } +} diff --git a/src/Notifications/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/Notifications/SmsGuardAlert.php b/src/Notifications/SmsGuardAlert.php new file mode 100644 index 00000000..94d6a48a --- /dev/null +++ b/src/Notifications/SmsGuardAlert.php @@ -0,0 +1,102 @@ +companyScope() ?? 'platform'); + $mode = (string) ($data['mode'] ?? 'enforce'); + $where = 'Scope ' . $scope . ($context->companyUuid ? ' (company ' . $context->companyUuid . ')' : '') . ', purpose ' . $context->purpose . ', source ' . $context->source . '.'; + $paused = array_key_exists('paused_minutes', $data) + ? ($mode === 'monitor' + ? 'The guard is in monitor mode, so nothing was paused.' + : ($data['paused_minutes'] === null + ? 'SMS is paused for this scope until an administrator unpauses it.' + : 'SMS is paused for this scope for ' . $data['paused_minutes'] . ' minutes.')) + : null; + + [$title, $what] = match ($signal) { + 'company_daily_budget' => ['tenant hit its daily SMS budget', 'The tenant sent ' . ($data['sends_today'] ?? '?') . ' messages today against a budget of ' . ($data['budget'] ?? '?') . '.'], + 'company_hourly_budget_repeated' => ['tenant hit its hourly SMS budget twice', 'The tenant reached its hourly budget of ' . ($data['budget'] ?? '?') . ' messages in ' . ($data['hours_at_budget_today'] ?? '?') . ' separate hours today.'], + 'distinct_destinations' => ['burst of SMS to new numbers', 'One tenant texted ' . ($data['distinct_destinations'] ?? '?') . ' distinct numbers inside ' . ($data['window_minutes'] ?? 10) . ' minutes. This is the SMS pumping pattern.'], + 'company_budget_threshold' => ['tenant at ' . ($data['percent'] ?? '?') . '% of its daily SMS budget', 'The tenant has sent ' . ($data['sends_today'] ?? '?') . ' of ' . ($data['budget'] ?? '?') . ' messages today.'], + 'platform_budget_threshold' => ['platform at ' . ($data['percent'] ?? '?') . '% of its hourly SMS budget', 'All tenants together have sent ' . ($data['sends_this_hour'] ?? '?') . ' of ' . ($data['budget'] ?? '?') . ' messages this hour.'], + 'new_destination_country' => ['SMS to a country new to the tenant', 'The tenant sent ' . ($data['sends_to_country'] ?? '?') . ' messages to ' . ($data['country'] ?? '?') . ' inside ' . ($data['window_minutes'] ?? 10) . ' minutes, a country it has not texted in the last 30 days (known: ' . implode(', ', (array) ($data['known_countries'] ?? [])) . ').'], + 'low_verification_conversion' => ['SMS codes going unverified', 'The tenant had ' . ($data['issued_last_hour'] ?? '?') . ' codes sent by SMS in the last hour and ' . ($data['verified_last_hour'] ?? '?') . ' verified. Real customers verify most of what they ask for.'], + default => ['SMS guard signal: ' . $signal, 'The SMS guard raised ' . $signal . '.'], + }; + + $lines = array_values(array_filter([ + $what, + $where, + $context->ip ? 'Last request from ' . $context->ip . ($context->userAgent ? ' (' . $context->userAgent . ')' : '') . ', to ' . $context->destination . '.' : 'Last message to ' . $context->destination . '.', + $paused, + 'Review the sms.blocked and sms.sent log entries for this scope. Manage pauses and the mode with the sms:pause, sms:unpause and sms:mode console commands.', + ])); + + return new static($signal, $title, $lines, array_merge($data, ['context' => $context->toArray()])); + } + + /** + * @return array + */ + public function via($notifiable) + { + return $notifiable instanceof User ? ['mail', 'database'] : ['mail']; + } + + /** + * @return MailMessage + */ + public function toMail($notifiable) + { + $message = (new MailMessage()) + ->error() + ->subject(config('app.name') . ' SMS guard: ' . $this->title); + + foreach ($this->lines as $line) { + $message->line($line); + } + + return $message; + } + + /** + * @return array + */ + public function toArray($notifiable) + { + return [ + 'signal' => $this->signal, + 'title' => $this->title, + 'lines' => $this->lines, + 'data' => $this->data, + ]; + } +} diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php index 37732a92..4abaaaa3 100644 --- a/src/Providers/CoreServiceProvider.php +++ b/src/Providers/CoreServiceProvider.php @@ -101,8 +101,12 @@ 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, + \Fleetbase\Console\Commands\SmsGuardMode::class, + \Fleetbase\Console\Commands\SmsGuardPause::class, + \Fleetbase\Console\Commands\SmsGuardUnpause::class, + \Fleetbase\Console\Commands\SmsLedgerPrune::class, ]; /** @@ -144,7 +148,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 +220,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/SmsService.php b/src/Services/SmsService.php index 4599acca..c9c3848c 100644 --- a/src/Services/SmsService.php +++ b/src/Services/SmsService.php @@ -2,6 +2,9 @@ namespace Fleetbase\Services; +use Fleetbase\Support\Sms\SmsContext; +use Fleetbase\Support\Sms\SmsGuard; +use Fleetbase\Support\Sms\SmsReservation; use Fleetbase\Twilio\Support\Laravel\Facade as Twilio; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; @@ -52,18 +55,39 @@ public function __construct() * * @param string $to Recipient phone number * @param string $text Message text - * @param array $options Additional options (from, provider, twilioParams, etc.) + * @param array $options Additional options (from, provider, twilioParams, etc.). Guard options: + * `purpose` (what the message is for), `company_uuid`, `source`, and + * `sms_reservation` when the caller already reserved the send. * @param string|null $provider Explicitly specify provider (overrides auto-routing) * * @return array Response containing status and provider information * - * @throws \Exception If SMS sending fails + * @throws \Fleetbase\Exceptions\SmsBlockedException If the SMS guard refuses the send + * @throws \Exception If SMS sending fails */ public function send(string $to, string $text, array $options = [], ?string $provider = null): array { // Normalize phone number $normalizedPhone = $this->normalizePhoneNumber($to); + // Every send passes the guard before a provider is called. A caller that already + // reserved the send (VerificationCode) hands its reservation over so it counts once. + $reservation = $options['sms_reservation'] ?? null; + if (!$reservation instanceof SmsReservation) { + $reservation = SmsGuard::make()->reserve(SmsContext::resolve($normalizedPhone, [ + 'purpose' => $options['purpose'] ?? 'sms', + 'company_uuid' => $options['company_uuid'] ?? null, + 'source' => $options['source'] ?? null, + ])); + } + + if ($reservation->isGuarded() && $reservation->country !== null) { + // The validator produced a canonical E.164 number; send to that + $normalizedPhone = $reservation->context->destination; + } + + $options = SmsGuard::providerOptions($options); + // Determine provider $selectedProvider = $provider ?? $this->determineProvider($normalizedPhone); @@ -92,6 +116,13 @@ public function send(string $to, string $text, array $options = [], ?string $pro 'to' => $to, ]); + $accepted = !(is_array($result) && array_key_exists('success', $result) && $result['success'] === false); + SmsGuard::make()->settle($reservation, $accepted, [ + 'provider' => $selectedProvider, + 'message_id' => $result['message_id'] ?? null, + 'error' => $accepted ? null : ($result['error'] ?? $result['message'] ?? null), + ]); + return $result; } catch (\Throwable $e) { Log::error('SMS sending failed', [ @@ -100,6 +131,8 @@ public function send(string $to, string $text, array $options = [], ?string $pro 'error' => $e->getMessage(), ]); + SmsGuard::make()->settle($reservation, false, ['provider' => $selectedProvider, 'error' => $e->getMessage()]); + throw $e; } } diff --git a/src/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/ApiModelCache.php b/src/Support/ApiModelCache.php index f6c3e069..7e598aba 100644 --- a/src/Support/ApiModelCache.php +++ b/src/Support/ApiModelCache.php @@ -2,10 +2,17 @@ namespace Fleetbase\Support; +use Illuminate\Container\Container; +use Illuminate\Database\DatabaseTransactionsManager; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasManyThrough; +use Illuminate\Database\Eloquent\Relations\MorphTo; +use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; /** * API Model Cache Manager. @@ -17,6 +24,21 @@ * - Automatic cache invalidation * - Multi-tenancy support * - Cache tagging for efficient bulk invalidation + * + * Caches are invalidated by versions embedded in their keys, not by the tag + * flush: list caches carry per table query versions (table wide and per company), + * single model and relationship caches per table model versions (their lookups + * are keyed by id and know no company). A key carries the versions of the + * model's own table and of every table its cached result embeds: the tables a + * model declares in `$apiCacheDependencies` (relation paths or table names), plus + * the relations a request (`with`, `expand`, `count`) or a model or relationship + * lookup loads. A write to any of those tables changes the key. Only what is + * loaded inside the cached callback goes stale; relations a resource lazy loads + * at render time are queried fresh on every hit. On Laravel 10 with phpredis >= 6.1, + * RedisTagSet::entries() starts ZSCAN with cursor '0', which phpredis treats as + * an already finished scan, so RedisTaggedCache::flush() deletes only the tag + * sets and leaves every tagged value in Redis until its TTL expires. Bumping the + * version moves readers to new keys and leaves the orphaned entries to expire. */ class ApiModelCache { @@ -50,8 +72,46 @@ class ApiModelCache */ public const RELATIONSHIP_TTL = 1800; + /** + * Session keys that scope list queries below the company (default). + * + * An API credential, or a Storefront store or network key, is resolved into + * these by middleware, and query callbacks filter on them without the value + * appearing in the query string. + */ + public const SESSION_SCOPE = ['api_credential', 'storefront_store', 'storefront_network']; + + /** + * Resolved dependency tables, keyed by model class, mode and relation path. + * + * @var array + */ + protected static array $dependencyTables = []; + + /** + * Most resolved paths kept per process; request paths are client supplied. + */ + protected const DEPENDENCY_MEMO_LIMIT = 1000; + + /** + * Most relation paths read from one request; the rest add no versions. + */ + protected const REQUEST_PATH_LIMIT = 50; + + /** + * Depth of withoutInvalidating() scopes currently running. + */ + protected static int $invalidationSuppressed = 0; + /** * Generate a cache key for a query. + * + * Besides the table, company and query string, the key covers the request path + * and the configured session scope. The path separates internal from public + * endpoints (which paginate and eager load differently) and endpoints whose + * callbacks read route parameters; the session scope separates API keys, stores + * and networks of one company. Both are hashed, so credentials never appear in + * the key or in the X-Cache-Key debug header. */ public static function generateQueryCacheKey(Model $model, Request $request, array $additionalParams = []): string { @@ -81,20 +141,334 @@ public static function generateQueryCacheKey(Model $model, Request $request, arr $params = array_filter($params, fn ($value) => $value !== null); ksort($params); - // Generate hash of parameters - $paramsHash = md5(json_encode($params)); + // Generate hash of parameters, the endpoint and the session scope + $paramsHash = md5(json_encode([ + 'params' => $params, + 'path' => $request->path(), + 'scope' => static::getSessionScope($request), + ])); // Include company UUID for multi-tenancy $companyPart = $companyUuid ? "company_{$companyUuid}" : 'no_company'; - // Get query version for this table/company + // Get query versions for this table and the tables its responses embed // This ensures cache keys change after invalidation - $versionKey = "api_query_version:{$table}:{$companyUuid}"; - $version = Cache::get($versionKey, 1); // Default to version 1 + $versionKeys = []; + foreach ([$table, ...static::dependencyTables($model, static::requestRelationPaths($request))] as $versionTable) { + $versionKeys[] = static::queryVersionKey($versionTable, null); + if ($companyUuid) { + $versionKeys[] = static::queryVersionKey($versionTable, $companyUuid); + } + } + $version = static::versionSegment($versionKeys); // Add Redis hash tag {api_query} for Redis Cluster shard routing // Include version to ensure cache keys change after writes - return "{api_query}:{$table}:{$companyPart}:v{$version}:{$paramsHash}"; + return "{api_query}:{$table}:{$companyPart}:{$version}:{$paramsHash}"; + } + + /** + * Cache key holding the query version for a table/company. + * + * A null company is the table wide version, which every company's list keys + * include, so writes that know no company invalidate all of them. + */ + protected static function queryVersionKey(string $table, ?string $companyUuid): string + { + return "api_query_version:{$table}:{$companyUuid}"; + } + + /** + * Cache key holding the model and relationship version for a table. + */ + protected static function modelVersionKey(string $table): string + { + return "api_model_version:{$table}"; + } + + /** + * Read versions in one round trip and join them into a key segment. + * + * @param string[] $versionKeys + */ + protected static function versionSegment(array $versionKeys): string + { + $versions = Cache::many($versionKeys); + + return 'v' . implode('.', array_map(fn ($version) => (int) $version, $versions)); + } + + /** + * Get the other tables whose data a model's cached results embed. + * + * Declared dependencies come from the model's `$apiCacheDependencies` and must + * resolve (an entry that does not is logged and skipped, or throws when + * api.cache.strict_dependencies is on); `$paths` are relations a request or lookup loads (`with`, `expand`, + * `$with`, a relationship name), and segments that cannot be resolved there + * end that path. + * + * @param string[] $paths + * + * @return string[] sorted table names, excluding the model's own table + */ + public static function dependencyTables(Model $model, array $paths = []): array + { + $declared = method_exists($model, 'getApiCacheDependencies') ? $model->getApiCacheDependencies() : []; + $tables = []; + + foreach ($declared as $path) { + array_push($tables, ...static::pathTables($model, $path, true)); + } + foreach ($paths as $path) { + array_push($tables, ...static::pathTables($model, $path, false)); + } + + $tables = array_values(array_unique(array_diff($tables, [$model->getTable()]))); + sort($tables); + + return $tables; + } + + /** + * Resolve one relation path, memoized per process. + * + * @return string[] + */ + protected static function pathTables(Model $model, string $path, bool $strict): array + { + $memoKey = get_class($model) . '|' . ($strict ? 'declared' : 'lookup') . '|' . $path; + if (array_key_exists($memoKey, static::$dependencyTables)) { + return static::$dependencyTables[$memoKey]; + } + + if (count(static::$dependencyTables) >= static::DEPENDENCY_MEMO_LIMIT) { + static::$dependencyTables = []; + } + + return static::$dependencyTables[$memoKey] = static::resolvePath($model, $path, $strict); + } + + /** + * Resolve a relation path (or, when declared, a table name) to tables. + * + * A relation contributes its related table, the pivot table of a many to many + * relation and the intermediate table of a has-many-through. A morphTo has no + * single related table, so declarations must name its tables instead. + * + * @return string[] + */ + protected static function resolvePath(Model $model, string $path, bool $strict): array + { + $tables = []; + $current = $model->newInstance(); + $segments = explode('.', $path); + + foreach ($segments as $segment) { + $method = static::relationMethod($current, trim($segment), $strict); + $relation = null; + if ($method) { + try { + // Like Eloquent's eager loading: a fresh instance, without the relation's constraints + $relation = Relation::noConstraints(fn () => $current->newInstance()->{$method}()); + } catch (\Throwable $e) { + // A relation that cannot be built on a blank model adds no versions + $relation = null; + } + } + + if (!$relation instanceof Relation || $relation instanceof MorphTo) { + if (!$strict) { + return $tables; + } + + // A declared entry that is not a relation name is a table name + if ($method === null && count($segments) === 1) { + return [$segment]; + } + + // Extensions deploy independently of core, so a bad declaration never + // fails a request; it is logged once per process, and throws only when + // api.cache.strict_dependencies is on (tests) + $message = sprintf('%s::$apiCacheDependencies entry "%s" cannot be resolved at "%s"; declare a relation path or the table names it reads.', get_class($model), $path, $segment); + if (config('api.cache.strict_dependencies', false)) { + throw new \LogicException($message); + } + Log::error($message); + + return $tables; + } + + if ($relation instanceof BelongsToMany) { + $tables[] = $relation->getTable(); + } + if ($relation instanceof HasManyThrough) { + $tables[] = $relation->getParent()->getTable(); + } + + $current = $relation->getRelated(); + $tables[] = $current->getTable(); + } + + return $tables; + } + + /** + * Find the method a relation segment names, as requests spell it (camel or snake case). + * + * Only public, non-static methods without required parameters are considered. + * Request paths are client supplied, so for them a method is only called when it + * declares a Relation return type: an untyped method could be anything (e.g. + * Order::dispatch() saves and fires events), and calling it is never worth a + * cache version. Declared paths come from the model's own code and may name + * untyped relation methods. + */ + protected static function relationMethod(Model $model, string $segment, bool $strict): ?string + { + foreach (array_unique([$segment, Str::camel($segment), Str::snake($segment)]) as $method) { + if ($method === '' || !method_exists($model, $method)) { + continue; + } + + $reflection = new \ReflectionMethod($model, $method); + if (!$reflection->isPublic() || $reflection->isStatic() || $reflection->getNumberOfRequiredParameters() > 0) { + continue; + } + if (!$strict && !static::declaresRelationReturn($reflection)) { + continue; + } + + return $method; + } + + return null; + } + + /** + * Whether a method's declared return type is an Eloquent relation. + */ + protected static function declaresRelationReturn(\ReflectionMethod $method): bool + { + $type = $method->getReturnType(); + + return $type instanceof \ReflectionNamedType + && !$type->isBuiltin() + && is_a($type->getName(), Relation::class, true); + } + + /** + * Get the relation paths a list request eager loads or counts. + * + * @return string[] + */ + protected static function requestRelationPaths(Request $request): array + { + $paths = []; + foreach (['with', 'expand', 'count', 'with_count'] as $param) { + $value = $request->input($param); + if (is_string($value)) { + $value = explode(',', $value); + } + if (is_array($value)) { + array_push($paths, ...array_filter($value, 'is_string')); + } + } + + return array_slice(array_values(array_unique(array_map('trim', $paths))), 0, static::REQUEST_PATH_LIMIT); + } + + /** + * Turn eager load definitions into relation paths. + * + * @param array $with + * + * @return string[] + */ + protected static function relationPaths(array $with): array + { + $paths = []; + foreach ($with as $key => $value) { + $path = is_string($key) ? $key : $value; + if (is_string($path)) { + $paths[] = explode(':', $path, 2)[0]; + } + } + + return $paths; + } + + /** + * Bump versions again when the write's transaction commits. + * + * Model events fire before the surrounding transaction commits, so a read in + * between sees the old rows under the new version and caches them. The second + * bump on commit retires anything cached in that window. The transaction may be + * on another connection than the model's (e.g. a storefront model written + * inside a transaction on the default connection), so this defers through the + * transactions manager all connections share. Outside a transaction the + * immediate bump stands alone. + * + * @param string[] $versionKeys + */ + protected static function bumpVersionsAfterCommit(Model $model, array $versionKeys): void + { + $bump = fn () => static::bumpVersions($versionKeys, $model); + + try { + $container = Container::getInstance(); + if ($container->bound('db.transactions')) { + $transactions = $container->make('db.transactions'); + if ($transactions instanceof DatabaseTransactionsManager && $transactions->callbackApplicableTransactions()->isNotEmpty()) { + $transactions->addCallback($bump); + } + + return; + } + + // No shared manager (e.g. a bare capsule): only the model's connection is known + if (Model::getConnectionResolver() !== null && $model->getConnection()->transactionLevel() > 0) { + $model->getConnection()->afterCommit($bump); + } + } catch (\Throwable $e) { + // Nothing to defer to; the immediate bump stands alone + } + } + + /** + * Advance versions without ever failing the write that triggered them. + * + * Invalidation runs inside model events, after-commit callbacks and write paths + * such as location pings. A cache outage there must not fail or roll back the + * write; the cached entries it would have retired expire within their TTL. + * + * @param string[] $versionKeys + */ + protected static function bumpVersions(array $versionKeys, Model $model): void + { + try { + foreach ($versionKeys as $versionKey) { + static::bumpVersion($versionKey); + } + } catch (\Throwable $e) { + Log::error('Failed to advance API cache versions', [ + 'model' => get_class($model), + 'keys' => $versionKeys, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * Advance a cache version so keys built from it change. + * + * A missing version reads as 0 and INCR creates it at 1, so the first bump + * also changes the key. Stores whose increment does not create missing keys + * (database, memcached) return false, so the version is seeded instead. + */ + protected static function bumpVersion(string $versionKey): void + { + if (Cache::increment($versionKey) === false) { + Cache::add($versionKey, 1) || Cache::increment($versionKey); + } } /** @@ -106,9 +480,12 @@ public static function generateModelCacheKey(Model $model, $id, array $with = [] { $table = $model->getTable(); $withHash = !empty($with) ? ':' . md5(json_encode($with)) : ''; + $tables = [$table, ...static::dependencyTables($model, static::relationPaths($with))]; + $version = static::versionSegment(array_map(fn ($versionTable) => static::modelVersionKey($versionTable), $tables)); // Add Redis hash tag {api_model} for Redis Cluster shard routing - return "{api_model}:{$table}:{$id}{$withHash}"; + // Include version to ensure cache keys change after writes + return "{api_model}:{$table}:{$version}:{$id}{$withHash}"; } /** @@ -116,11 +493,14 @@ public static function generateModelCacheKey(Model $model, $id, array $with = [] */ public static function generateRelationshipCacheKey(Model $model, string $relationshipName): string { - $table = $model->getTable(); - $id = $model->getKey(); + $table = $model->getTable(); + $id = $model->getKey(); + $tables = [$table, ...static::dependencyTables($model, [$relationshipName])]; + $version = static::versionSegment(array_map(fn ($versionTable) => static::modelVersionKey($versionTable), $tables)); // Add Redis hash tag {api_relation} for Redis Cluster shard routing - return "{api_relation}:{$table}:{$id}:{$relationshipName}"; + // Carries the parent's versions and the related tables', so a write to either side changes it + return "{api_relation}:{$table}:{$version}:{$id}:{$relationshipName}"; } /** @@ -160,17 +540,19 @@ public static function cacheQueryResult(Model $model, Request $request, \Closure return $result ?? collect([]); // Guard against null } - $cacheKey = static::generateQueryCacheKey($model, $request, $additionalParams); - $companyUuid = static::getCompanyUuid($request); - $tags = static::generateCacheTags($model, $companyUuid, true); // Include query tag - $ttl = $ttl ?? static::LIST_TTL; + $cacheKey = null; + $ttl = $ttl ?? static::LIST_TTL; // FIX #3: Guard against read-after-invalidate in same request - // After invalidation, resetCacheStatus() sets $cacheStatus to null - // But we need a way to detect if invalidation happened in this request - // For now, we'll rely on tag flush working correctly + // After invalidation, resetCacheStatus() sets $cacheStatus to null and the + // query version has moved, so the key generated below is already the new one try { + // Inside the try, so a cache outage while reading versions falls back to the query + $cacheKey = static::generateQueryCacheKey($model, $request, $additionalParams); + $companyUuid = static::getCompanyUuid($request); + $tags = static::generateCacheTags($model, $companyUuid, true); // Include query tag + // Initialize cache status and key static::$cacheStatus = null; static::$cacheKey = $cacheKey; @@ -313,7 +695,7 @@ public static function cacheRelationship(Model $model, string $relationshipName, */ public static function invalidateModelCache(Model $model, ?string $companyUuid = null): void { - if (!static::isCachingEnabled()) { + if (!static::isCachingEnabled() || static::$invalidationSuppressed > 0) { return; } @@ -322,11 +704,13 @@ public static function invalidateModelCache(Model $model, ?string $companyUuid = // after invalidation within the same request lifecycle static::resetCacheStatus(); - // FIX #4: Increment query version counter - // This ensures the cache key changes after invalidation - $table = $model->getTable(); - $versionKey = "api_query_version:{$table}:{$companyUuid}"; - Cache::increment($versionKey); + // FIX #4: Increment the query and model version counters + // This ensures cache keys change after invalidation, and it is what + // actually hides stale caches (see the class docblock) + $table = $model->getTable(); + $versionKeys = [static::queryVersionKey($table, $companyUuid), static::modelVersionKey($table)]; + static::bumpVersions($versionKeys, $model); + static::bumpVersionsAfterCommit($model, $versionKeys); // Generate tags for BOTH model and query caches // Model caches: single-record lookups @@ -339,7 +723,8 @@ public static function invalidateModelCache(Model $model, ?string $companyUuid = Cache::tags($modelTags)->flush(); // Flush query-level caches (collections, lists) - // This is CRITICAL - query caches must be explicitly flushed + // On Redis these flushes remove the tag sets but not the values (see the + // class docblock); the version bumps above are what invalidate caches Cache::tags($queryTags)->flush(); } catch (\Exception $e) { Log::error('Failed to invalidate cache', [ @@ -350,6 +735,31 @@ public static function invalidateModelCache(Model $model, ?string $companyUuid = } } + /** + * Run a callback without invalidating API caches. + * + * For high frequency writes (telemetry pings) whose model events should still + * fire, but whose cache invalidation the caller coalesces itself, e.g. once per + * window per table. Everything else the callback writes is not invalidated + * either, so keep the callback to the write itself. + * + * @template T + * + * @param \Closure(): T $callback + * + * @return T + */ + public static function withoutInvalidating(\Closure $callback): mixed + { + static::$invalidationSuppressed++; + + try { + return $callback(); + } finally { + static::$invalidationSuppressed--; + } + } + /** * Invalidate cache for a specific query. */ @@ -433,7 +843,7 @@ public static function getRelationshipTtl(): int protected static function getCompanyUuid(Request $request): ?string { // Try to get from session - if ($request->session()->has('company')) { + if ($request->hasSession() && $request->session()->has('company')) { return $request->session()->get('company'); } @@ -447,6 +857,31 @@ protected static function getCompanyUuid(Request $request): ?string return $request->input('company_uuid'); } + /** + * Extract the configured session scope values from request. + */ + protected static function getSessionScope(Request $request): array + { + if (!$request->hasSession()) { + return []; + } + + $keys = config('api.cache.scope.session') ?? static::SESSION_SCOPE; + $scope = []; + + foreach ($keys as $key) { + $value = $request->session()->get($key); + + if ($value !== null) { + $scope[$key] = $value; + } + } + + ksort($scope); + + return $scope; + } + /** * Warm up cache for a model. */ diff --git a/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/NotificationRegistry.php b/src/Support/NotificationRegistry.php index 2714dfe0..f8c96902 100644 --- a/src/Support/NotificationRegistry.php +++ b/src/Support/NotificationRegistry.php @@ -391,9 +391,35 @@ protected static function resolveNotificationSettings(...$params): mixed return Setting::lookupForCompany($companyUuid ?? session('company'), 'notification_settings', []); } + /** + * Determine whether a notifiable definition was registered with `registerNotifiable()`. + * + * @param mixed $definition a notifiable class name or a `dynamic:` name + */ + public static function isRegisteredNotifiable($definition): bool + { + if (!is_string($definition) || $definition === '') { + return false; + } + + $definition = ltrim($definition, '\\'); + foreach (static::$notifiables as $notifiable) { + if (is_string($notifiable) && ltrim($notifiable, '\\') === $definition) { + return true; + } + } + + return false; + } + /** * Resolve a notifiable object to an Eloquent model. * + * The notifiable object comes from the company's saved notification settings, so only registered + * notifiables are resolved. An unregistered `dynamic:` would reach any public method on the + * subject that takes no arguments, through `resolveDynamicNotifiable()` or Eloquent's relation getter, + * and an unregistered class would be built by the container. Anything else is skipped. + * * @param array $notifiableObject an associative array containing the definition and primary key to resolve the notifiable object * * @return Model|null the Eloquent model or null if it cannot be resolved @@ -404,6 +430,10 @@ protected static function resolveNotifiable(array $notifiableObject, $subject = $primaryKey = data_get($notifiableObject, 'primaryKey'); $key = data_get($notifiableObject, 'key'); + if (!static::isRegisteredNotifiable($definition)) { + return null; + } + // Handle dynamic subject resolution if (Str::startsWith($definition, 'dynamic:')) { $property = str_replace('dynamic:', '', $definition); diff --git a/src/Support/RequestRelations.php b/src/Support/RequestRelations.php new file mode 100644 index 00000000..dce3f2ca --- /dev/null +++ b/src/Support/RequestRelations.php @@ -0,0 +1,205 @@ +newInstance()->{$name}()`, so an + * unchecked name runs any public method that needs no arguments on a blank model: `with[]=saveInstance` + * inserts an empty row, and fleetops' `Order::dispatch()` saves an order and queues its events. + * + * A name is accepted when the method (or the Expandable closure registered under that name) declares an + * Eloquent Relation return type, or when the model lists it in a `$withable` array. Each segment of a + * nested path is checked against the model the previous segment relates to, and the path is cut at the + * first segment that fails. Nothing after a morphTo is kept, since its related model is only known per + * result. Rejected names are dropped without an error. + */ +class RequestRelations +{ + /** + * Most paths read from one request. + */ + public const MAX_PATHS = 50; + + /** + * Most segments kept in one nested path. + */ + public const MAX_DEPTH = 5; + + /** + * Get the eager load paths a request may apply to a model. + * + * @param mixed $relations a comma-separated string or a list of dot paths + * + * @return string[] the accepted paths, with each segment named as the model's method + */ + public static function eagerLoads(Model $model, mixed $relations): array + { + $paths = []; + foreach (static::split($relations) as $path) { + $current = $model; + $segments = []; + foreach (explode('.', $path) as $segment) { + $method = static::relationMethod($current, $segment); + $relation = $method ? static::relation($current, $method) : null; + if (!$relation) { + break; + } + + $segments[] = $method; + if ($relation instanceof MorphTo || count($segments) >= static::MAX_DEPTH) { + break; + } + + $current = $relation->getRelated(); + } + + if ($segments) { + $paths[] = implode('.', $segments); + } + } + + return array_values(array_unique($paths)); + } + + /** + * Get the relation counts a request may apply to a model. + * + * @param mixed $relations a comma-separated string or a list of relation names + * + * @return string[] the accepted relation names, named as the model's methods + */ + public static function counts(Model $model, mixed $relations): array + { + $names = []; + foreach (static::split($relations) as $name) { + $method = str_contains($name, '.') ? null : static::relationMethod($model, $name); + if ($method === null) { + continue; + } + + $relation = static::relation($model, $method); + if ($relation && !$relation instanceof MorphTo) { + $names[] = $method; + } + } + + return array_values(array_unique($names)); + } + + /** + * Resolve a requested segment to the name of a relation method on the model. + * + * The segment is tried camel-cased, as given, then snake-cased. A method is returned as declared, so + * `subfleets` resolves to `subFleets` and the relation is loaded under the key resources read. + */ + public static function relationMethod(Model $model, string $segment): ?string + { + $segment = trim($segment); + if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $segment)) { + return null; + } + + $withable = array_map('strtolower', static::withable($model)); + foreach (array_unique([Str::camel($segment), $segment, Str::snake($segment)]) as $name) { + $callable = static::callable($model, $name); + if (!$callable) { + continue; + } + + $name = $callable instanceof \ReflectionMethod ? $callable->getName() : $name; + if (in_array(strtolower($name), $withable, true) || static::declaresRelation($callable->getReturnType())) { + return $name; + } + } + + return null; + } + + /** + * Flatten every accepted input shape to a list of trimmed, non-empty paths. + * + * @return string[] + */ + protected static function split(mixed $relations): array + { + if (is_string($relations)) { + $relations = explode(',', $relations); + } + if (!is_array($relations)) { + return []; + } + + $paths = array_map('trim', array_filter($relations, 'is_string')); + + return array_slice(array_values(array_unique(array_filter($paths, fn (string $path) => $path !== ''))), 0, static::MAX_PATHS); + } + + /** + * Get the method, or the Expandable closure, a relation name calls, when it can be called with no arguments. + */ + protected static function callable(Model $model, string $name): ?\ReflectionFunctionAbstract + { + if (method_exists($model, $name)) { + $method = new \ReflectionMethod($model, $name); + + return $method->isPublic() && !$method->isStatic() && $method->getNumberOfRequiredParameters() === 0 ? $method : null; + } + + // isExpansion() only holds for a registered Closure + if (method_exists($model, 'isExpansion') && $model::isExpansion($name)) { + $closure = new \ReflectionFunction($model::getExpansionClosure($name)); + + return $closure->getNumberOfRequiredParameters() === 0 ? $closure : null; + } + + return null; + } + + /** + * Whether a declared return type names an Eloquent relation (`BelongsTo`, `?HasMany`, `BelongsTo|Builder`). + */ + protected static function declaresRelation(?\ReflectionType $type): bool + { + $types = $type instanceof \ReflectionUnionType ? $type->getTypes() : [$type]; + foreach ($types as $member) { + if ($member instanceof \ReflectionNamedType && !$member->isBuiltin() && is_a($member->getName(), Relation::class, true)) { + return true; + } + } + + return false; + } + + /** + * Get the relation names a model allows beyond those with a declared Relation return type. + * + * @return string[] + */ + protected static function withable(Model $model): array + { + $withable = (fn () => property_exists($this, 'withable') ? $this->withable : [])->call($model); + + return is_array($withable) ? array_values(array_filter($withable, 'is_string')) : []; + } + + /** + * Build the relation the same way Eloquent does for an eager load, or null when it is not one. + */ + protected static function relation(Model $model, string $method): ?Relation + { + try { + $relation = Relation::noConstraints(fn () => $model->newInstance()->{$method}()); + } catch (\Throwable) { + return null; + } + + return $relation instanceof Relation ? $relation : null; + } +} diff --git a/src/Support/Sms/PhoneValidator.php b/src/Support/Sms/PhoneValidator.php new file mode 100644 index 00000000..6c56ef91 --- /dev/null +++ b/src/Support/Sms/PhoneValidator.php @@ -0,0 +1,156 @@ + true, 'e164' => $normalized !== '' ? $normalized : null, 'country' => null, 'type' => null, 'reason' => null]; + + $util = PhoneNumberUtil::getInstance(); + $parsed = null; + if ($normalized !== '') { + try { + $parsed = $util->parse($normalized, null); + } catch (NumberParseException $e) { + $parsed = null; + } + } + + if (!$parsed || !$util->isValidNumber($parsed)) { + return $enabled ? self::refuse($result, self::REASON_INVALID) : $result; + } + + $result['e164'] = $util->format($parsed, PhoneNumberFormat::E164); + $result['country'] = $util->getRegionCodeForNumber($parsed) ?: null; + $result['type'] = self::typeName($util->getNumberType($parsed)); + + if (!$enabled) { + return $result; + } + + $allowedTypes = self::upperList($rules['allowed_types'] ?? self::DEFAULT_ALLOWED_TYPES); + if ($allowedTypes && !in_array((string) $result['type'], $allowedTypes, true)) { + return self::refuse($result, self::REASON_TYPE_NOT_ALLOWED); + } + + foreach (self::list($rules['blocked_prefixes'] ?? []) as $prefix) { + $prefix = '+' . ltrim(preg_replace('/[^0-9+]/', '', $prefix), '+'); + if (strlen($prefix) > 1 && str_starts_with($result['e164'], $prefix)) { + return self::refuse($result, self::REASON_PREFIX_BLOCKED); + } + } + + $blockedCountries = self::upperList($rules['blocked_countries'] ?? []); + if ($result['country'] && in_array($result['country'], $blockedCountries, true)) { + return self::refuse($result, self::REASON_COUNTRY_BLOCKED); + } + + $allowedCountries = self::upperList($rules['allowed_countries'] ?? []); + if ($allowedCountries && !in_array((string) $result['country'], $allowedCountries, true)) { + return self::refuse($result, self::REASON_COUNTRY_NOT_ALLOWED); + } + + return $result; + } + + /** + * Strip formatting and give the number a leading `+`, the way the rest of Fleetbase + * expects destinations (a `00` international prefix becomes `+`). + */ + public static function normalize(string $phone): string + { + $digits = preg_replace('/[^0-9+]/', '', trim($phone)) ?? ''; + $digits = preg_replace('/(?!^)\+/', '', $digits) ?? ''; + + if ($digits === '' || $digits === '+') { + return ''; + } + + if (str_starts_with($digits, '00')) { + $digits = '+' . substr($digits, 2); + } + + return str_starts_with($digits, '+') ? $digits : '+' . $digits; + } + + /** + * What to tell the caller. Deliberately says nothing about which rule matched beyond + * "invalid" versus "cannot receive texts", so a list of blocked ranges cannot be probed. + */ + public static function messageFor(?string $reason): string + { + return match ($reason) { + self::REASON_INVALID => 'The phone number provided is not valid.', + self::REASON_TYPE_NOT_ALLOWED => 'The phone number provided cannot receive text messages.', + default => 'Text messages cannot be sent to this phone number.', + }; + } + + /** + * The libphonenumber type as its constant name (MOBILE, PREMIUM_RATE, ...). + */ + public static function typeName(int $type): string + { + $name = array_search($type, (new \ReflectionClass(PhoneNumberType::class))->getConstants(), true); + + return is_string($name) ? $name : 'UNKNOWN'; + } + + private static function refuse(array $result, string $reason): array + { + $result['valid'] = false; + $result['reason'] = $reason; + + return $result; + } + + private static function list(mixed $value): array + { + if (is_string($value)) { + $value = explode(',', $value); + } + + return array_values(array_filter(array_map(fn ($item) => trim((string) $item), (array) $value), fn ($item) => $item !== '')); + } + + private static function upperList(mixed $value): array + { + return array_map('strtoupper', self::list($value)); + } +} diff --git a/src/Support/Sms/SmsBreaker.php b/src/Support/Sms/SmsBreaker.php new file mode 100644 index 00000000..d017ce42 --- /dev/null +++ b/src/Support/Sms/SmsBreaker.php @@ -0,0 +1,404 @@ +cache->get($this->key('pause:' . $scope)); + if (!is_array($pause)) { + $pause = SmsPolicy::settings()['pauses'][$scope] ?? null; + } + + if (!is_array($pause)) { + return null; + } + + if (($pause['until'] ?? null) !== null && (int) $pause['until'] <= time()) { + return null; + } + + return $pause; + } + + /** + * Pause a scope for `$minutes`, or until unpaused when null. + */ + public function pause(string $scope, ?int $minutes, string $reason, bool $auto = false, ?string $companyUuid = null): array + { + $pause = [ + 'until' => $minutes === null ? null : time() + ($minutes * 60), + 'reason' => $reason, + 'auto' => $auto, + 'company_uuid' => $companyUuid, + 'at' => time(), + ]; + + if ($minutes === null) { + $this->cache->forever($this->key('pause:' . $scope), $pause); + } else { + $this->cache->put($this->key('pause:' . $scope), $pause, $minutes * 60); + } + + try { + SmsPolicy::update(['pauses' => [$scope => $pause]]); + } catch (\Throwable $e) { + Log::warning('sms.pause_not_persisted', ['scope' => $scope, 'error' => $e->getMessage()]); + } + + Log::warning('sms.paused', array_merge($pause, ['scope' => $scope])); + + return $pause; + } + + public function unpause(string $scope): void + { + $this->cache->forget($this->key('pause:' . $scope)); + + try { + SmsPolicy::update(['pauses' => [$scope => null]]); + } catch (\Throwable $e) { + Log::warning('sms.unpause_not_persisted', ['scope' => $scope, 'error' => $e->getMessage()]); + } + + Log::info('sms.unpaused', ['scope' => $scope]); + } + + /** + * Current pauses from the policy, expired ones dropped. + */ + public function pauses(): array + { + return array_filter(SmsPolicy::settings()['pauses'], fn ($pause) => $pause['until'] === null || $pause['until'] > time()); + } + + /** + * A scope key for what an operator typed: "platform", a scope key as is, or a company + * uuid or public id resolved to its company scope. Null when no company matches. + */ + public static function resolveScope(string $input): ?string + { + $input = trim($input); + + if ($input === self::SCOPE_PLATFORM || str_starts_with($input, 'company:') || str_starts_with($input, 'tenant:')) { + return $input; + } + + try { + $company = Company::withoutGlobalScopes() + ->where('uuid', $input) + ->orWhere('public_id', $input) + ->first(['uuid']); + } catch (\Throwable $e) { + return null; + } + + return $company ? 'company:' . $company->uuid : null; + } + + public static function companyUuidFromScope(string $scope): ?string + { + return str_starts_with($scope, 'company:') ? substr($scope, strlen('company:')) : null; + } + + /** + * Read the counters after a reserved send and trip whatever crossed a line. + * + * @param array $counters the guard's counters for this send + */ + public function observe(SmsContext $context, array $limits, array $counters, RateLimiter $limiter, bool $monitor, array $settings, ?string $country = null): void + { + $breaker = $settings['breaker']; + + if ($context->companyUuid && $limits['company']) { + $this->observeCompanyBudget($context, $counters, $limiter, $breaker, $monitor); + } + + if (!$limits['trusted']) { + $this->observeDistinctDestinations($context, $breaker, $monitor); + $this->observeNewCountry($context, $country, $breaker, $monitor); + $this->observeConversion($context, $breaker, $monitor); + } + + $this->observePlatform($context, $counters, $limiter, $breaker, $monitor); + } + + /** + * A burst of sends to a country the company has never texted. Learned from the ledger + * rather than configured, since tenants' countries cannot be guessed; armed only once + * the company has enough history for "never" to mean something. + */ + protected function observeNewCountry(SmsContext $context, ?string $country, array $breaker, bool $monitor): void + { + $threshold = $breaker['new_country_min_sends'] ?? null; + if (!$threshold || !$country || !$context->companyUuid) { + return; + } + + if (SmsLedger::sentCountFor($context->companyUuid) < ($breaker['new_country_min_history'] ?? 0)) { + return; + } + + $known = SmsLedger::countriesFor($context->companyUuid); + if (!$known || in_array(strtoupper($country), $known, true)) { + return; + } + + $scope = $context->tenantScope() ?? $context->companyScope(); + $count = $this->increment('new-country:' . $scope . ':' . strtoupper($country) . ':' . intdiv(time(), self::DISTINCT_WINDOW), self::DISTINCT_WINDOW); + if ($count >= $threshold) { + $this->trip($scope, self::SIGNAL_NEW_COUNTRY, $context, $breaker, $monitor, ['country' => strtoupper($country), 'sends_to_country' => $count, 'known_countries' => $known, 'window_minutes' => self::DISTINCT_WINDOW / 60]); + } + } + + /** + * Codes issued by SMS in the last hour against codes actually verified. Real customers + * verify most of what they ask for; pumping verifies nothing. + */ + protected function observeConversion(SmsContext $context, array $breaker, bool $monitor): void + { + $minIssued = $breaker['conversion_min_issued'] ?? null; + if (!$minIssued || !$context->companyUuid) { + return; + } + + $stats = $this->conversionFor($context->companyUuid); + if ($stats === null || $stats['issued'] < $minIssued) { + return; + } + + if ($stats['verified'] * 100 < $stats['issued'] * ($breaker['conversion_min_percent'] ?? 0)) { + $this->trip($context->companyScope(), self::SIGNAL_LOW_CONVERSION, $context, $breaker, $monitor, ['issued_last_hour' => $stats['issued'], 'verified_last_hour' => $stats['verified']]); + } + } + + /** + * Codes issued by SMS and verified for a company in the last hour, cached for a minute; + * null until the verification code table carries the tracing columns. + * + * @return array{issued: int, verified: int}|null + */ + protected function conversionFor(string $companyUuid): ?array + { + if (!VerificationCode::supportsTracingColumns()) { + return null; + } + + try { + return $this->cache->remember($this->key('conversion:' . $companyUuid), 60, function () use ($companyUuid) { + $since = Carbon::now()->subHour(); + $base = VerificationCode::withoutGlobalScopes()->where('company_uuid', $companyUuid)->where('channel', 'sms')->where('created_at', '>=', $since); + + return [ + 'issued' => (int) (clone $base)->count(), + 'verified' => (int) (clone $base)->whereNotNull('consumed_at')->count(), + ]; + }); + } catch (\Throwable $e) { + return null; + } + } + + protected function observeCompanyBudget(SmsContext $context, array $counters, RateLimiter $limiter, array $breaker, bool $monitor): void + { + $scope = $context->companyScope(); + $day = $this->counter($counters, 'company', 'per_day'); + $hour = $this->counter($counters, 'company', 'per_hour'); + + if ($day) { + $attempts = $limiter->attempts($day['key']); + + if ($attempts >= $day['max']) { + $this->trip($scope, self::SIGNAL_COMPANY_DAILY_BUDGET, $context, $breaker, $monitor, ['sends_today' => $attempts, 'budget' => $day['max']]); + } else { + foreach ($breaker['alert_percentages'] as $percent) { + if ($attempts >= (int) ceil($day['max'] * $percent / 100) && $this->once('alert:' . $scope . ':day:' . $percent . ':' . intdiv(time(), 86400), 86400)) { + $this->alert(self::SIGNAL_BUDGET_THRESHOLD, $context, ['percent' => $percent, 'sends_today' => $attempts, 'budget' => $day['max'], 'scope' => $scope, 'mode' => $monitor ? SmsPolicy::MODE_MONITOR : SmsPolicy::MODE_ENFORCE]); + } + } + } + } + + if ($hour && $limiter->attempts($hour['key']) >= $hour['max']) { + if ($this->once('hour-trip:' . $scope . ':' . intdiv(time(), 3600), 86400)) { + $trips = $this->increment('hour-trips:' . $scope, 86400); + if ($trips >= 2) { + $this->trip($scope, self::SIGNAL_COMPANY_HOURLY_REPEAT, $context, $breaker, $monitor, ['hours_at_budget_today' => $trips, 'budget' => $hour['max']]); + } + } + } + } + + protected function observeDistinctDestinations(SmsContext $context, array $breaker, bool $monitor): void + { + $threshold = $breaker['distinct_destinations_per_10m']; + $scope = $context->tenantScope() ?? $context->companyScope(); + if (!$threshold || !$scope) { + return; + } + + $bucket = intdiv(time(), self::DISTINCT_WINDOW); + if (!$this->once('distinct:' . $scope . ':' . $bucket . ':' . sha1($context->destination), self::DISTINCT_WINDOW)) { + return; + } + + $count = $this->increment('distinct-count:' . $scope . ':' . $bucket, self::DISTINCT_WINDOW); + if ($count >= $threshold) { + $this->trip($scope, self::SIGNAL_DISTINCT_DESTINATIONS, $context, $breaker, $monitor, ['distinct_destinations' => $count, 'window_minutes' => self::DISTINCT_WINDOW / 60]); + } + } + + protected function observePlatform(SmsContext $context, array $counters, RateLimiter $limiter, array $breaker, bool $monitor): void + { + $hour = $this->counter($counters, 'platform', 'per_hour'); + if (!$hour) { + return; + } + + $attempts = $limiter->attempts($hour['key']); + $percent = max($breaker['alert_percentages'] ?: [80]); + if ($attempts >= (int) ceil($hour['max'] * $percent / 100) && $this->once('alert:platform:hour:' . intdiv(time(), 3600), 3600)) { + $this->alert(self::SIGNAL_PLATFORM_THRESHOLD, $context, ['percent' => $percent, 'sends_this_hour' => $attempts, 'budget' => $hour['max'], 'scope' => self::SCOPE_PLATFORM, 'mode' => $monitor ? SmsPolicy::MODE_MONITOR : SmsPolicy::MODE_ENFORCE]); + } + } + + /** + * Pause a scope (unless monitoring) and alert once an hour per signal. + */ + protected function trip(string $scope, string $signal, SmsContext $context, array $breaker, bool $monitor, array $data): void + { + if (!$monitor && $this->pausedUntil($scope)) { + return; + } + + $minutes = $breaker['pause_minutes']; + $reason = $signal; + + if (!$monitor) { + $autoPauses = $context->companyScope() ? $this->increment('auto-pauses:' . $context->companyScope(), 86400) : 1; + if ($autoPauses >= $breaker['auto_pauses_before_manual']) { + $minutes = null; + $reason .= ' (paused ' . $autoPauses . ' times today; an administrator must unpause)'; + } + + $this->pause($scope, $minutes, $reason, true, $context->companyUuid); + } + + if ($this->once('alert:' . $scope . ':' . $signal, self::ALERT_WINDOW)) { + $this->alert($signal, $context, array_merge($data, [ + 'scope' => $scope, + 'paused_minutes' => $minutes, + 'mode' => $monitor ? SmsPolicy::MODE_MONITOR : SmsPolicy::MODE_ENFORCE, + ])); + } + } + + /** + * Send an alert to the administrators. Never throws: an alert that cannot be delivered + * must not fail the send that triggered it. + */ + protected function alert(string $signal, SmsContext $context, array $data): void + { + $alert = SmsGuardAlert::for($signal, $context, $data); + + Log::warning('sms.alert', array_merge(['signal' => $signal], $context->toArray(), $data)); + + try { + $this->dispatchAlert($alert); + } catch (\Throwable $e) { + Log::error('sms.alert_not_sent', ['signal' => $signal, 'error' => $e->getMessage()]); + } + } + + protected function dispatchAlert(SmsGuardAlert $alert): void + { + $admins = SmsPolicy::adminUsers(); + $adminEmails = $admins->pluck('email')->filter()->map(fn ($email) => strtolower(trim((string) $email)))->all(); + $extra = array_values(array_diff(SmsPolicy::alertRecipients(), $adminEmails)); + + if ($admins->isNotEmpty()) { + Notification::sendNow($admins, $alert); + } + + if ($extra) { + Notification::route('mail', $extra)->notifyNow($alert); + } + } + + protected function counter(array $counters, string $scope, string $window): ?array + { + foreach ($counters as $counter) { + if ($counter['scope'] === $scope && $counter['window'] === $window) { + return $counter; + } + } + + return null; + } + + /** + * True the first time a key is seen within its window. + */ + protected function once(string $key, int $seconds): bool + { + return (bool) $this->cache->add($this->key($key), 1, $seconds); + } + + protected function increment(string $key, int $seconds): int + { + $this->cache->add($this->key($key), 0, $seconds); + + return (int) $this->cache->increment($this->key($key)); + } + + protected function key(string $key): string + { + return SmsGuard::CACHE_PREFIX . $key; + } +} diff --git a/src/Support/Sms/SmsContext.php b/src/Support/Sms/SmsContext.php new file mode 100644 index 00000000..1b472110 --- /dev/null +++ b/src/Support/Sms/SmsContext.php @@ -0,0 +1,197 @@ +ip(), + userAgent: $overrides['user_agent'] ?? ($request && $request->userAgent() ? Str::limit((string) $request->userAgent(), 250, '') : null), + subjectType: is_object($subject) ? get_class($subject) : null, + subjectUuid: is_object($subject) && data_get($subject, 'uuid') ? (string) data_get($subject, 'uuid') : null, + ); + } + + /** + * The same context with the destination replaced, after the validator normalised it. + */ + public function withDestination(string $destination): self + { + return new self( + $this->purpose, + $destination, + $this->companyUuid, + $this->tenantType, + $this->tenantUuid, + $this->source, + $this->ip, + $this->userAgent, + $this->subjectType, + $this->subjectUuid, + ); + } + + /** + * Whether an anonymous caller, or one holding only a public storefront key or an + * API credential, triggered this send. These are the sends that fail closed when the + * guard's counters are unreachable. + */ + public function isPublic(): bool + { + return in_array($this->source, [self::SOURCE_STOREFRONT, self::SOURCE_API, self::SOURCE_PUBLIC], true); + } + + public function companyScope(): ?string + { + return $this->companyUuid ? 'company:' . $this->companyUuid : null; + } + + public function tenantScope(): ?string + { + return $this->tenantUuid ? 'tenant:' . ($this->tenantType ?: 'tenant') . ':' . $this->tenantUuid : null; + } + + public function toArray(): array + { + return [ + 'purpose' => $this->purpose, + 'destination' => $this->destination, + 'company_uuid' => $this->companyUuid, + 'tenant_type' => $this->tenantType, + 'tenant_uuid' => $this->tenantUuid, + 'source' => $this->source, + 'ip' => $this->ip, + 'user_agent' => $this->userAgent, + 'subject_type' => $this->subjectType, + 'subject_uuid' => $this->subjectUuid, + ]; + } + + /** + * Which surface a request came in on, from its first path segment. + * + * Onboarding is anonymous even though it lives under the console prefix, so it counts + * as public: a self-signup farm must not get the console's lenient treatment. + */ + public static function detectSource(?Request $request): string + { + if (!$request) { + return self::SOURCE_SYSTEM; + } + + $path = trim((string) $request->path(), '/'); + if ($path === '') { + return self::SOURCE_SYSTEM; + } + + $first = Str::before($path, '/'); + $storefrontPrefix = trim((string) config('storefront.api.routing.prefix', 'storefront'), '/'); + $internalPrefix = trim((string) config('fleetbase.api.routing.internal_prefix', 'int'), '/'); + + if ($storefrontPrefix !== '' && $first === $storefrontPrefix) { + return self::SOURCE_STOREFRONT; + } + + if ($internalPrefix !== '' && $first === $internalPrefix) { + return Str::contains($path, '/onboard') ? self::SOURCE_PUBLIC : self::SOURCE_CONSOLE; + } + + if ($first === 'public' || Str::contains($path, '/public/')) { + return self::SOURCE_PUBLIC; + } + + if ($first === 'v1' || Str::contains($path, '/v1/')) { + return self::SOURCE_API; + } + + return self::SOURCE_SYSTEM; + } + + private static function sessionValues(): array + { + try { + $session = session(); + + return [ + 'company' => $session->get('company'), + 'storefront_store' => $session->get('storefront_store'), + 'storefront_network' => $session->get('storefront_network'), + ]; + } catch (\Throwable $e) { + return []; + } + } + + private static function currentRequest(): ?Request + { + try { + $request = request(); + + return $request instanceof Request ? $request : null; + } catch (\Throwable $e) { + return null; + } + } +} diff --git a/src/Support/Sms/SmsGuard.php b/src/Support/Sms/SmsGuard.php new file mode 100644 index 00000000..b783261f --- /dev/null +++ b/src/Support/Sms/SmsGuard.php @@ -0,0 +1,320 @@ +bound(static::class)) { + return app(static::class); + } + } catch (\Throwable $e) { + // no container: fall through to a fresh guard + } + + return new static(); + } + + /** + * Check a send against the policy and, when it may go ahead, count it. + * + * @throws SmsBlockedException when the send is refused + */ + public function reserve(SmsContext $context): SmsReservation + { + $settings = SmsPolicy::settings(); + if (!$settings['enabled']) { + return new SmsReservation($context, SmsReservation::DECISION_BYPASSED); + } + + $monitor = $settings['mode'] === SmsPolicy::MODE_MONITOR; + $limits = SmsPolicy::limitsFor($context, $settings); + $wouldBlock = null; + + $validation = PhoneValidator::validate($context->destination, array_merge($settings['validation'], ['allowed_countries' => $limits['allowed_countries']])); + if (!$validation['valid']) { + $wouldBlock = $this->refuse($context, SmsBlockedException::invalidDestination((string) $validation['reason'], $context->toArray()), $monitor); + } elseif ($validation['e164'] && $validation['e164'] !== $context->destination) { + $context = $context->withDestination($validation['e164']); + } + + try { + $counters = $this->counters($context, $limits); + + if ($wouldBlock === null) { + foreach ($this->pauseScopes($context, $limits) as $scope) { + if ($pause = $this->breaker()->pausedUntil($scope)) { + $retryAfter = $pause['until'] !== null ? max(1, (int) $pause['until'] - time()) : null; + $wouldBlock = $this->refuse($context, SmsBlockedException::paused($scope, $retryAfter, $context->toArray()), $monitor); + break; + } + } + } + + if ($wouldBlock === null) { + foreach ($counters as $counter) { + if ($this->limiter()->tooManyAttempts($counter['key'], $counter['max'])) { + $wouldBlock = $this->refuse($context, SmsBlockedException::limited($counter['reason'], (int) $this->limiter()->availableIn($counter['key']), array_merge($context->toArray(), ['scope' => $counter['scope'], 'window' => $counter['window']])), $monitor); + break; + } + } + } + + foreach ($counters as $counter) { + $this->limiter()->hit($counter['key'], $counter['decay']); + } + + $this->breaker()->observe($context, $limits, $counters, $this->limiter(), $monitor, $settings, $validation['country']); + } catch (SmsBlockedException $e) { + throw $e; + } catch (\Throwable $e) { + // The counter store is unreachable. Public sends fail closed: refusing to pump + // SMS blind is the point. Trusted and internal sends (2FA, console actions) fail + // open so staff can still sign in while the store is being fixed. + Log::warning('sms.guard_unavailable', array_merge($context->toArray(), ['error' => $e->getMessage()])); + + if (!$monitor && $context->isPublic() && !$limits['trusted']) { + throw SmsBlockedException::unavailable($context->toArray()); + } + + return $this->ledgered(new SmsReservation($context, SmsReservation::DECISION_DEGRADED, null, $validation['country'], $validation['type'])); + } + + return $this->ledgered(new SmsReservation($context, $wouldBlock === null ? SmsReservation::DECISION_ALLOWED : SmsReservation::DECISION_MONITOR, $wouldBlock, $validation['country'], $validation['type'])); + } + + /** + * Put a send that is going ahead on the ledger as reserved; settle() records how it ended. + */ + protected function ledgered(SmsReservation $reservation): SmsReservation + { + $reservation->sendUuid = SmsLedger::record($reservation->context, SmsSend::STATUS_RESERVED, [ + 'destination_country' => $reservation->country, + 'destination_type' => $reservation->type, + 'meta' => array_filter(['decision' => $reservation->decision, 'would_block' => $reservation->wouldBlock]), + ]); + + return $reservation; + } + + /** + * Record how a reserved send ended. + */ + public function settle(SmsReservation $reservation, bool $success, array $details = []): void + { + if ($reservation->settled) { + return; + } + + $reservation->settled = true; + + if (!$reservation->isGuarded()) { + return; + } + + $entry = array_merge($reservation->toArray(), Arr::only($details, ['provider', 'message_id', 'error'])); + + if ($success) { + Log::info('sms.sent', $entry); + } else { + Log::warning('sms.failed', $entry); + } + + SmsLedger::update($reservation->sendUuid, [ + 'status' => $success ? SmsSend::STATUS_SENT : SmsSend::STATUS_FAILED, + 'provider' => $details['provider'] ?? null, + 'provider_message_id' => isset($details['message_id']) ? (string) $details['message_id'] : null, + 'error' => $details['error'] ?? null, + ]); + } + + public function breaker(): SmsBreaker + { + return $this->breaker ??= new SmsBreaker($this->cache()); + } + + public function limiter(): RateLimiter + { + return $this->limiter ??= new RateLimiter($this->cache()); + } + + /** + * Options a provider should see: everything the caller passed minus the guard's own. + */ + public static function providerOptions(array $options): array + { + return Arr::except($options, self::OPTIONS); + } + + /** + * The counters a send is checked against and then counted in, in check order. + * + * @return array + */ + public function counters(SmsContext $context, array $limits): array + { + $counters = []; + $destination = $limits['destination']; + $purpose = $context->purpose; + $number = $context->destination; + + if (($destination['cooldown_seconds'] ?? null) > 0) { + $counters[] = $this->counter('dest:' . $purpose . ':' . $number . ':cooldown', 1, (int) $destination['cooldown_seconds'], 'cooldown', 'destination', 'cooldown'); + } + if (($destination['per_hour'] ?? null) !== null) { + $counters[] = $this->counter('dest:' . $purpose . ':' . $number . ':hour', (int) $destination['per_hour'], 3600, 'destination_limit', 'destination', 'per_hour'); + } + if (($destination['per_day'] ?? null) !== null) { + $counters[] = $this->counter('dest:' . $purpose . ':' . $number . ':day', (int) $destination['per_day'], 86400, 'destination_limit', 'destination', 'per_day'); + } + + if ($limits['ip'] && $context->ip) { + $owner = $context->companyScope() ?? SmsBreaker::SCOPE_PLATFORM; + if (($limits['ip']['per_15_minutes'] ?? null) !== null) { + $counters[] = $this->counter('ip:' . $owner . ':' . $context->ip . ':15m', (int) $limits['ip']['per_15_minutes'], 900, 'ip_limit', 'ip', 'per_15_minutes'); + } + if (($limits['ip']['per_day'] ?? null) !== null) { + $counters[] = $this->counter('ip:' . $owner . ':' . $context->ip . ':day', (int) $limits['ip']['per_day'], 86400, 'ip_limit', 'ip', 'per_day'); + } + } + + if ($limits['tenant'] && $context->tenantScope()) { + foreach (SmsPolicy::WINDOWS as $window => $decay) { + if (($limits['tenant'][$window] ?? null) !== null) { + $counters[] = $this->counter($context->tenantScope() . ':' . $window, (int) $limits['tenant'][$window], $decay, 'tenant_limit', 'tenant', $window); + } + } + } + + if ($limits['company'] && $context->companyScope()) { + foreach (SmsPolicy::WINDOWS as $window => $decay) { + if (($limits['company'][$window] ?? null) !== null) { + $counters[] = $this->counter($context->companyScope() . ':' . $window, (int) $limits['company'][$window], $decay, 'company_limit', 'company', $window); + } + } + } + + foreach (SmsPolicy::WINDOWS as $window => $decay) { + if (($limits['platform'][$window] ?? null) !== null) { + $counters[] = $this->counter('platform:' . $limits['bucket'] . ':' . $window, (int) $limits['platform'][$window], $decay, 'platform_limit', 'platform', $window); + } + } + + return $counters; + } + + /** + * Which pauses stop this send. Trusted purposes (2FA, admin actions) answer only to a + * platform pause: a pumped tenant must not lock its own staff out of the console. + */ + protected function pauseScopes(SmsContext $context, array $limits): array + { + $scopes = [SmsBreaker::SCOPE_PLATFORM]; + + if ($limits['trusted']) { + return $scopes; + } + + if ($context->companyScope()) { + $scopes[] = $context->companyScope(); + } + + if ($context->tenantScope()) { + $scopes[] = $context->tenantScope(); + } + + return $scopes; + } + + /** + * Log a refusal and, unless monitoring, throw it. Returns the reason when monitoring. + */ + protected function refuse(SmsContext $context, SmsBlockedException $exception, bool $monitor): string + { + $entry = array_merge($context->toArray(), ['reason' => $exception->reason(), 'status' => $exception->status(), 'retry_after' => $exception->retryAfter()]); + + if ($monitor) { + Log::warning('sms.would_block', $entry); + + return $exception->reason(); + } + + Log::warning('sms.blocked', $entry); + SmsLedger::record($context, SmsSend::STATUS_BLOCKED, ['block_reason' => $exception->reason()]); + + throw $exception; + } + + protected function counter(string $key, int $max, int $decay, string $reason, string $scope, string $window): array + { + return ['key' => self::CACHE_PREFIX . $key, 'max' => $max, 'decay' => $decay, 'reason' => $reason, 'scope' => $scope, 'window' => $window]; + } + + /** + * The cache store the counters live in. Must be shared by every worker and task (Redis + * in production); the configured `sms.guard.cache_store` is used when it resolves. + */ + protected function cache(): CacheRepository + { + if ($this->cache) { + return $this->cache; + } + + $cache = app('cache'); + $store = config('sms.guard.cache_store'); + + if (is_object($cache) && method_exists($cache, 'store')) { + if (filled($store)) { + try { + return $this->cache = $cache->store($store); + } catch (\Throwable $e) { + Log::warning('sms.guard_cache_store_unavailable', ['store' => $store, 'error' => $e->getMessage()]); + } + } + + return $this->cache = $cache->store(); + } + + return $this->cache = $cache; + } +} diff --git a/src/Support/Sms/SmsLedger.php b/src/Support/Sms/SmsLedger.php new file mode 100644 index 00000000..da13281c --- /dev/null +++ b/src/Support/Sms/SmsLedger.php @@ -0,0 +1,193 @@ + $context->companyUuid, + 'tenant_type' => $context->tenantType, + 'tenant_uuid' => $context->tenantUuid, + 'purpose' => $context->purpose, + 'source' => $context->source, + 'destination' => $context->destination, + 'destination_hash' => self::hash($context->destination), + 'status' => $status, + 'subject_type' => $context->subjectType, + 'subject_uuid' => $context->subjectUuid, + 'ip_address' => $context->ip, + 'user_agent' => $context->userAgent, + ], $attributes)); + + return $send->uuid; + } catch (\Throwable $e) { + self::markUnavailable($e); + + return null; + } + } + + /** + * Update a row with how the send ended. + */ + public static function update(?string $uuid, array $attributes): void + { + if (!$uuid || !self::available()) { + return; + } + + try { + SmsSend::where('uuid', $uuid)->update(array_merge($attributes, ['updated_at' => Carbon::now()])); + } catch (\Throwable $e) { + self::markUnavailable($e); + } + } + + /** + * Countries a company has sent to in the last `$days`, cached for ten minutes. + * + * @return array + */ + public static function countriesFor(string $companyUuid, int $days = 30): array + { + if (!self::available()) { + return []; + } + + try { + return Cache::remember('sms-guard:countries:' . $companyUuid, 600, function () use ($companyUuid, $days) { + return SmsSend::where('company_uuid', $companyUuid) + ->where('status', SmsSend::STATUS_SENT) + ->where('created_at', '>=', Carbon::now()->subDays($days)) + ->whereNotNull('destination_country') + ->distinct() + ->pluck('destination_country') + ->map(fn ($country) => strtoupper((string) $country)) + ->values() + ->all(); + }); + } catch (\Throwable $e) { + return []; + } + } + + /** + * How many sends a company has on the ledger in the last `$days`, cached for ten minutes. + */ + public static function sentCountFor(string $companyUuid, int $days = 30): int + { + if (!self::available()) { + return 0; + } + + try { + return (int) Cache::remember('sms-guard:sent-count:' . $companyUuid, 600, function () use ($companyUuid, $days) { + return SmsSend::where('company_uuid', $companyUuid) + ->where('status', SmsSend::STATUS_SENT) + ->where('created_at', '>=', Carbon::now()->subDays($days)) + ->count(); + }); + } catch (\Throwable $e) { + return 0; + } + } + + /** + * Apply retention: mask the full number on rows older than `$maskAfterDays` (the hash + * and the last four digits stay, so per-number limits and forensics still work), and + * delete rows older than `$retentionDays`. + * + * @return array{masked: int, deleted: int} + */ + public static function prune(int $retentionDays, int $maskAfterDays): array + { + $masked = 0; + $deleted = 0; + + if ($retentionDays > 0) { + $deleted = SmsSend::where('created_at', '<', Carbon::now()->subDays($retentionDays))->delete(); + } + + if ($maskAfterDays > 0) { + $rows = SmsSend::where('created_at', '<', Carbon::now()->subDays($maskAfterDays)) + ->whereNotNull('destination') + ->where('destination', 'not like', '•%') + ->get(['uuid', 'destination']); + + foreach ($rows as $row) { + SmsSend::where('uuid', $row->uuid)->update(['destination' => self::mask($row->destination)]); + $masked++; + } + } + + return ['masked' => $masked, 'deleted' => (int) $deleted]; + } + + /** + * `•••4821`: the last four digits. + */ + public static function mask(string $destination): string + { + return '•••' . substr(preg_replace('/\D/', '', $destination) ?? '', -4); + } + + public static function hash(string $destination): string + { + return hash('sha256', PhoneValidator::normalize($destination)); + } + + /** + * Whether the ledger can be written: a database is bound and the table did not fail + * within the last minute. + */ + public static function available(): bool + { + if (Model::getConnectionResolver() === null) { + return false; + } + + try { + return !Cache::has(self::UNAVAILABLE_KEY); + } catch (\Throwable $e) { + return true; + } + } + + private static function markUnavailable(\Throwable $e): void + { + Log::warning('sms.ledger_unavailable', ['error' => $e->getMessage()]); + + try { + Cache::put(self::UNAVAILABLE_KEY, 1, 60); + } catch (\Throwable $ignored) { + // nothing to remember it in; the next write tries again + } + } +} diff --git a/src/Support/Sms/SmsPolicy.php b/src/Support/Sms/SmsPolicy.php new file mode 100644 index 00000000..bc88eace --- /dev/null +++ b/src/Support/Sms/SmsPolicy.php @@ -0,0 +1,453 @@ + 60, 'per_hour' => 3600, 'per_day' => 86400]; + + /** + * The policy from the environment alone. + */ + public static function defaults(): array + { + return static::normalize((array) config('sms.guard', [])); + } + + /** + * The administrator's stored override, raw, or [] when there is none. + */ + public static function stored(): array + { + if (!static::hasDatabase()) { + return []; + } + + try { + $value = Setting::where('key', 'system.' . static::SETTING_KEY)->value('value'); + } catch (\Throwable $e) { + return []; + } + + return is_array($value) ? $value : []; + } + + /** + * Whether Eloquent has a connection to query at all. Only a bare container (unit tests) + * lacks one, and a query there does not fail cleanly, so the lookups are skipped. + */ + protected static function hasDatabase(): bool + { + return Model::getConnectionResolver() !== null; + } + + /** + * The effective policy: defaults with the stored override applied. + */ + public static function settings(): array + { + try { + $stored = Cache::remember(static::SETTINGS_CACHE_KEY, static::SETTINGS_CACHE_TTL, fn () => static::stored()); + } catch (\Throwable $e) { + $stored = []; + } + + return static::normalize(static::merge((array) config('sms.guard', []), is_array($stored) ? $stored : [])); + } + + /** + * Replace the stored override and make it effective at once. + */ + public static function store(array $override): array + { + Setting::configureSystem(static::SETTING_KEY, $override); + static::flush(); + + return static::settings(); + } + + /** + * Change part of the stored override, keeping the rest. + */ + public static function update(array $changes): array + { + return static::store(static::merge(static::stored(), $changes)); + } + + /** + * Drop the stored override so the environment defaults apply again. + */ + public static function reset(): array + { + Setting::where('key', 'system.' . static::SETTING_KEY)->delete(); + static::flush(); + + return static::settings(); + } + + public static function flush(): void + { + try { + Cache::forget(static::SETTINGS_CACHE_KEY); + } catch (\Throwable $e) { + // the next read falls back to the database + } + } + + public static function isEnabled(): bool + { + return static::settings()['enabled']; + } + + public static function mode(): string + { + return static::settings()['mode']; + } + + /** + * Coerce a policy into its canonical shape. + */ + public static function normalize(array $settings): array + { + $limits = (array) ($settings['limits'] ?? []); + $validation = (array) ($settings['validation'] ?? []); + $breaker = (array) ($settings['breaker'] ?? []); + $platform = (array) ($limits['platform'] ?? []); + $mode = strtolower(trim((string) ($settings['mode'] ?? static::MODE_ENFORCE))); + + $purposes = []; + foreach ((array) ($settings['purposes'] ?? []) as $purpose => $rules) { + if (!is_string($purpose) || $purpose === '' || !is_array($rules)) { + continue; + } + + $purposes[$purpose] = [ + 'trusted' => (bool) ($rules['trusted'] ?? false), + 'destination' => static::destinationLimits((array) ($rules['destination'] ?? []), false), + ]; + } + + $overrides = []; + foreach ((array) ($settings['overrides'] ?? []) as $override) { + $companyUuid = data_get($override, 'company_uuid'); + if (!is_string($companyUuid) || $companyUuid === '') { + continue; + } + + $overrides[$companyUuid] = [ + 'company_uuid' => $companyUuid, + 'unlimited' => (bool) data_get($override, 'unlimited', false), + 'per_minute' => static::limit(data_get($override, 'per_minute')), + 'per_hour' => static::limit(data_get($override, 'per_hour')), + 'per_day' => static::limit(data_get($override, 'per_day')), + 'allowed_countries' => static::upperList(data_get($override, 'allowed_countries', [])), + 'note' => (string) data_get($override, 'note', ''), + ]; + } + + $pauses = []; + foreach ((array) ($settings['pauses'] ?? []) as $scope => $pause) { + if (!is_string($scope) || $scope === '' || !is_array($pause)) { + continue; + } + + $pauses[$scope] = [ + 'until' => isset($pause['until']) && $pause['until'] !== null ? (int) $pause['until'] : null, + 'reason' => (string) ($pause['reason'] ?? ''), + 'auto' => (bool) ($pause['auto'] ?? false), + 'company_uuid' => isset($pause['company_uuid']) ? (string) $pause['company_uuid'] : null, + 'at' => (int) ($pause['at'] ?? 0), + ]; + } + + return [ + 'enabled' => (bool) ($settings['enabled'] ?? true), + 'mode' => in_array($mode, [static::MODE_ENFORCE, static::MODE_MONITOR], true) ? $mode : static::MODE_ENFORCE, + 'cache_store' => filled($settings['cache_store'] ?? null) ? (string) $settings['cache_store'] : null, + 'alert_emails' => static::emails($settings['alert_emails'] ?? []), + 'validation' => [ + 'enabled' => (bool) ($validation['enabled'] ?? true), + 'allowed_types' => static::upperList($validation['allowed_types'] ?? PhoneValidator::DEFAULT_ALLOWED_TYPES) ?: PhoneValidator::DEFAULT_ALLOWED_TYPES, + 'blocked_countries' => static::upperList($validation['blocked_countries'] ?? []), + 'blocked_prefixes' => static::list($validation['blocked_prefixes'] ?? []), + ], + 'limits' => [ + 'destination' => static::destinationLimits((array) ($limits['destination'] ?? []), true), + 'ip' => [ + 'per_15_minutes' => static::limit(data_get($limits, 'ip.per_15_minutes', 5)), + 'per_day' => static::limit(data_get($limits, 'ip.per_day', 20)), + ], + 'company' => static::windowLimits((array) ($limits['company'] ?? []), ['per_minute' => 10, 'per_hour' => 60, 'per_day' => 200]), + 'tenant' => static::windowLimits((array) ($limits['tenant'] ?? []), []), + 'probation' => [ + 'days' => max(0, (int) data_get($limits, 'probation.days', 7)), + 'per_day' => static::limit(data_get($limits, 'probation.per_day', 20)), + ], + 'platform' => [ + static::BUCKET_PUBLIC => static::windowLimits((array) ($platform[static::BUCKET_PUBLIC] ?? []), ['per_minute' => 100, 'per_hour' => 1500, 'per_day' => 10000]), + static::BUCKET_TRUSTED => static::windowLimits((array) ($platform[static::BUCKET_TRUSTED] ?? []), ['per_minute' => 100, 'per_hour' => 1500, 'per_day' => 10000]), + ], + ], + 'breaker' => [ + 'pause_minutes' => max(1, (int) ($breaker['pause_minutes'] ?? 60)), + 'alert_percentages' => array_values(array_filter(array_map('intval', static::list($breaker['alert_percentages'] ?? [50, 80])), fn ($pct) => $pct > 0 && $pct < 100)), + 'distinct_destinations_per_10m' => static::limit($breaker['distinct_destinations_per_10m'] ?? 25), + 'auto_pauses_before_manual' => max(1, (int) ($breaker['auto_pauses_before_manual'] ?? 2)), + 'new_country_min_sends' => static::limit($breaker['new_country_min_sends'] ?? 10), + 'new_country_min_history' => max(0, (int) ($breaker['new_country_min_history'] ?? 50)), + 'conversion_min_issued' => static::limit($breaker['conversion_min_issued'] ?? 20), + 'conversion_min_percent' => max(0, min(100, (int) ($breaker['conversion_min_percent'] ?? 10))), + ], + 'ledger' => [ + 'retention_days' => max(0, (int) data_get($settings, 'ledger.retention_days', 90)), + 'mask_after_days' => max(0, (int) data_get($settings, 'ledger.mask_after_days', 30)), + ], + 'purposes' => $purposes, + 'overrides' => array_values($overrides), + 'pauses' => $pauses, + ]; + } + + /** + * The administrator's exception for a company, if any. + */ + public static function overrideFor(?string $companyUuid, ?array $settings = null): ?array + { + if (!$companyUuid) { + return null; + } + + foreach (($settings ?? static::settings())['overrides'] as $override) { + if ($override['company_uuid'] === $companyUuid) { + return $override; + } + } + + return null; + } + + public static function isTrusted(string $purpose, ?array $settings = null): bool + { + return (bool) data_get($settings ?? static::settings(), 'purposes.' . $purpose . '.trusted', false); + } + + /** + * The limits that apply to one send. + * + * @return array{trusted: bool, bucket: string, destination: array, ip: ?array, tenant: ?array, company: ?array, company_unlimited: bool, platform: array, allowed_countries: array, probation: bool} + */ + public static function limitsFor(SmsContext $context, ?array $settings = null): array + { + $settings ??= static::settings(); + $override = static::overrideFor($context->companyUuid, $settings); + $purpose = $settings['purposes'][$context->purpose] ?? ['trusted' => false, 'destination' => []]; + $trusted = (bool) ($purpose['trusted'] ?? false); + $probation = false; + + $destination = $settings['limits']['destination']; + foreach ((array) ($purpose['destination'] ?? []) as $key => $value) { + if ($value !== null) { + $destination[$key] = $value; + } + } + + // Trusted purposes (2FA, admin actions) answer to their own platform bucket and the + // destination limits only: a pumped tenant must still be able to sign its staff in. + $company = null; + if ($context->companyUuid && !$trusted && !($override['unlimited'] ?? false)) { + $company = $settings['limits']['company']; + + if ($override) { + foreach (array_keys(static::WINDOWS) as $window) { + if ($override[$window] !== null) { + $company[$window] = $override[$window]; + } + } + } elseif (static::isOnProbation($context->companyUuid, $settings['limits']['probation']['days'])) { + $probation = true; + $limit = $settings['limits']['probation']['per_day']; + if ($limit !== null) { + $company['per_day'] = $company['per_day'] === null ? $limit : min($company['per_day'], $limit); + } + } + } + + $tenant = null; + if (!$trusted && $context->tenantUuid) { + $tenant = array_filter($settings['limits']['tenant'], fn ($value) => $value !== null) ?: null; + } + + $bucket = $trusted ? static::BUCKET_TRUSTED : static::BUCKET_PUBLIC; + + return [ + 'trusted' => $trusted, + 'bucket' => $bucket, + 'destination' => $destination, + 'ip' => $trusted ? null : $settings['limits']['ip'], + 'tenant' => $tenant, + 'company' => $company, + 'company_unlimited' => (bool) ($override['unlimited'] ?? false), + 'platform' => $settings['limits']['platform'][$bucket], + 'allowed_countries' => $override['allowed_countries'] ?? [], + 'probation' => $probation, + ]; + } + + /** + * Whether a company is younger than the probation period. Unknown companies are not. + */ + public static function isOnProbation(?string $companyUuid, int $days): bool + { + if (!$companyUuid || $days <= 0 || !static::hasDatabase()) { + return false; + } + + try { + $createdAt = Cache::remember('sms_guard:company_created:' . $companyUuid, static::COMPANY_CACHE_TTL, function () use ($companyUuid) { + $value = Company::withoutGlobalScopes()->where('uuid', $companyUuid)->value('created_at'); + + return $value ? Carbon::parse($value)->toIso8601String() : ''; + }); + } catch (\Throwable $e) { + return false; + } + + return filled($createdAt) && Carbon::parse($createdAt)->gt(Carbon::now()->subDays($days)); + } + + /** + * Where alerts go: the addresses in the policy, plus every system administrator. + * Tenants are never alerted. + * + * @return array + */ + public static function alertRecipients(?array $settings = null): array + { + $emails = ($settings ?? static::settings())['alert_emails']; + + foreach (static::adminUsers() as $admin) { + if (filled($admin->email)) { + $emails[] = strtolower(trim((string) $admin->email)); + } + } + + return array_values(array_unique(array_filter($emails, fn ($email) => filter_var($email, FILTER_VALIDATE_EMAIL)))); + } + + /** + * The system administrators, for in-app notifications. + * + * @return Collection + */ + public static function adminUsers(): Collection + { + if (!static::hasDatabase()) { + return new Collection(); + } + + try { + return User::withoutGlobalScopes()->where('type', 'admin')->whereNull('deleted_at')->get(); + } catch (\Throwable $e) { + return new Collection(); + } + } + + /** + * Apply an override to a base policy. Associative arrays merge key by key; lists and + * scalars are replaced, so a stored `allowed_types` of two entries does not pick up a + * third from the defaults. + */ + public static function merge(array $base, array $override): array + { + foreach ($override as $key => $value) { + if (is_array($value) && isset($base[$key]) && is_array($base[$key]) && !array_is_list($value) && !array_is_list($base[$key])) { + $base[$key] = static::merge($base[$key], $value); + } else { + $base[$key] = $value; + } + } + + return $base; + } + + protected static function destinationLimits(array $limits, bool $withDefaults): array + { + $defaults = $withDefaults ? ['cooldown_seconds' => 60, 'per_hour' => 3, 'per_day' => 5] : ['cooldown_seconds' => null, 'per_hour' => null, 'per_day' => null]; + + return [ + 'cooldown_seconds' => static::limit($limits['cooldown_seconds'] ?? $defaults['cooldown_seconds']), + 'per_hour' => static::limit($limits['per_hour'] ?? $defaults['per_hour']), + 'per_day' => static::limit($limits['per_day'] ?? $defaults['per_day']), + ]; + } + + protected static function windowLimits(array $limits, array $defaults): array + { + $result = []; + foreach (array_keys(static::WINDOWS) as $window) { + $result[$window] = static::limit(array_key_exists($window, $limits) ? $limits[$window] : ($defaults[$window] ?? null)); + } + + return $result; + } + + /** + * A limit as an integer, or null for "no limit" (null, '', 'none', 'unlimited'). + */ + protected static function limit(mixed $value): ?int + { + if ($value === null || $value === '' || (is_string($value) && in_array(strtolower(trim($value)), ['none', 'unlimited', 'null'], true))) { + return null; + } + + return max(0, (int) $value); + } + + protected static function list(mixed $value): array + { + if (is_string($value)) { + $value = explode(',', $value); + } + + return array_values(array_filter(array_map(fn ($item) => trim((string) $item), (array) $value), fn ($item) => $item !== '')); + } + + protected static function upperList(mixed $value): array + { + return array_map('strtoupper', static::list($value)); + } + + protected static function emails(mixed $value): array + { + return array_values(array_unique(array_filter(array_map('strtolower', static::list($value)), fn ($email) => filter_var($email, FILTER_VALIDATE_EMAIL)))); + } +} diff --git a/src/Support/Sms/SmsReservation.php b/src/Support/Sms/SmsReservation.php new file mode 100644 index 00000000..cfa66aec --- /dev/null +++ b/src/Support/Sms/SmsReservation.php @@ -0,0 +1,82 @@ +decision !== self::DECISION_BYPASSED; + } + + /** + * What a verification code row keeps about the send, so an incident can be traced + * from the database: who it was for, where it came from and what the guard decided. + */ + public function trace(): array + { + if (!$this->isGuarded()) { + return []; + } + + return array_filter([ + 'company_uuid' => $this->context->companyUuid, + 'tenant_type' => $this->context->tenantType, + 'tenant_uuid' => $this->context->tenantUuid, + 'source' => $this->context->source, + 'ip' => $this->context->ip, + 'user_agent' => $this->context->userAgent, + 'destination_country' => $this->country, + 'decision' => $this->decision, + 'would_block' => $this->wouldBlock, + 'sms_send_uuid' => $this->sendUuid, + ], fn ($value) => $value !== null); + } + + public function toArray(): array + { + return array_merge($this->context->toArray(), [ + 'decision' => $this->decision, + 'would_block' => $this->wouldBlock, + 'destination_country' => $this->country, + 'destination_type' => $this->type, + 'sms_send_uuid' => $this->sendUuid, + ]); + } +} 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/Traits/HasApiControllerBehavior.php b/src/Traits/HasApiControllerBehavior.php index d3395472..1bbb7afb 100644 --- a/src/Traits/HasApiControllerBehavior.php +++ b/src/Traits/HasApiControllerBehavior.php @@ -582,7 +582,7 @@ public function deleteRecord($id, Request $request) // cross-tenant deletion (GHSA-3wj9-hh56-7fw7). No global scope backs this // up — removing it reopens that vulnerability. $companyUuid = session('company'); - if ($companyUuid && $this->model->isColumn($this->model->qualifyColumn('company_uuid'))) { + if ($companyUuid && $this->model->isColumn('company_uuid')) { $builder->where($this->model->qualifyColumn('company_uuid'), $companyUuid); } diff --git a/src/Traits/HasApiModelBehavior.php b/src/Traits/HasApiModelBehavior.php index 89c29cd3..29c8545a 100644 --- a/src/Traits/HasApiModelBehavior.php +++ b/src/Traits/HasApiModelBehavior.php @@ -7,6 +7,7 @@ use Fleetbase\Support\Auth; use Fleetbase\Support\Http; use Fleetbase\Support\QueryOptimizer; +use Fleetbase\Support\RequestRelations; use Fleetbase\Support\Resolve; use Fleetbase\Support\Utils; use Illuminate\Http\JsonResponse; @@ -353,13 +354,13 @@ public function createRecordFromRequest($request, ?callable $onBefore = null, ?c // PERFORMANCE OPTIMIZATION: Use load() instead of re-querying the database // This avoids an unnecessary second database query - $with = static::normalizeRequestRelations($request->or(['with', 'expand'], [])); + $with = RequestRelations::eagerLoads($record, $request->or(['with', 'expand'], [])); if (!empty($with)) { $record->load($with); } // Load counts if requested - $withCount = $request->array('with_count', []); + $withCount = RequestRelations::counts($record, $request->input('with_count', [])); if (!empty($withCount)) { $record->loadCount($withCount); } @@ -456,13 +457,13 @@ public function updateRecordFromRequest(Request $request, $id, ?callable $onBefo // PERFORMANCE OPTIMIZATION: Use load() instead of re-querying the database // This avoids an unnecessary second database query - $with = static::normalizeRequestRelations($request->or(['with', 'expand'], [])); + $with = RequestRelations::eagerLoads($record, $request->or(['with', 'expand'], [])); if (!empty($with)) { $record->load($with); } // Load counts if requested - $withCount = $request->array('with_count', []); + $withCount = RequestRelations::counts($record, $request->input('with_count', [])); if (!empty($withCount)) { $record->loadCount($withCount); } @@ -548,9 +549,6 @@ public function bulkRemove($ids = []) */ public static function mutateModelWithRequest(Request $request, $result) { - $with = static::normalizeRequestRelations($request->or(['with', 'expand'], [])); - $without = $request->array('without'); - // handle collection or array of results if (is_array($result) || $result instanceof \Illuminate\Support\Collection) { return collect($result)->map( @@ -560,30 +558,25 @@ function ($model) use ($request) { ); } + if (!$result instanceof \Illuminate\Database\Eloquent\Model) { + return $result; + } + + $with = RequestRelations::eagerLoads($result, $request->or(['with', 'expand'], [])); + $without = array_filter($request->array('without'), 'is_string'); + if ($with) { $result->load($with); } + // makeHidden adds to the model's hidden attributes; setHidden would replace them and expose the defaults if ($without) { - $result->setHidden($without); + $result->makeHidden($without); } return $result; } - protected static function normalizeRequestRelations($relations) - { - if (empty($relations)) { - return []; - } - - $relations = is_array($relations) ? $relations : explode(',', $relations); - - return array_map(function ($relation) { - return implode('.', array_map(fn ($part) => Str::camel(trim($part)), explode('.', $relation))); - }, $relations); - } - /** * Fills the target array with session attributes based on the specified rules. * Allows to apply exceptions and only specific attributes to be filled. @@ -640,33 +633,9 @@ public function withRelationships(Request $request, $builder) return $builder; } - $contains = is_array($with) ? $with : explode(',', $with); - - foreach ($contains as $contain) { - $camelVersion = Str::camel(trim($contain)); - if (\method_exists($this, $camelVersion)) { - $builder->with($camelVersion); - continue; - } - - $snakeCase = Str::snake(trim($contain)); - if (\method_exists($this, $snakeCase)) { - $builder->with(trim($snakeCase)); - continue; - } - - if (strpos($contain, '.') !== false) { - $parts = array_map( - function ($part) { - return Str::camel($part); - }, - explode('.', $contain) - ); - $contain = implode('.', $parts); - - $builder->with($contain); - continue; - } + $contains = RequestRelations::eagerLoads($this, $with); + if ($contains) { + $builder->with($contains); } if ($without) { @@ -692,19 +661,9 @@ public function withCounts($request, $builder) return $builder; } - $counters = explode(',', $count); - - foreach ($counters as $counter) { - if (\method_exists($this, $counter)) { - $builder->withCount($counter); - continue; - } - - $camelVersion = Str::camel($counter); - if (\method_exists($this, $camelVersion)) { - $builder->withCount($camelVersion); - continue; - } + $counters = RequestRelations::counts($this, $count); + if ($counters) { + $builder->withCount($counters); } return $builder; @@ -965,16 +924,9 @@ public function applyCustomFilters(Request $request, $builder) $without = $request->array('without'); $withoutRelations = $request->boolean('without_relations'); - // camelcase all params in with and apply + // apply only the requested names that are relations of this model if (is_array($with)) { - $with = array_map( - function ($relationship) { - return Str::camel($relationship); - }, - $with - ); - - $builder = $builder->with($with); + $builder = $builder->with(RequestRelations::eagerLoads($this, $with)); } // camelcase all params in with and apply diff --git a/src/Traits/HasApiModelCache.php b/src/Traits/HasApiModelCache.php index 195b89cf..5416696f 100644 --- a/src/Traits/HasApiModelCache.php +++ b/src/Traits/HasApiModelCache.php @@ -250,6 +250,25 @@ public function loadMultipleCached($relationships) return $this; } + /** + * Get the relation paths or table names this model's cached responses embed. + * + * Declare them on the model as `protected $apiCacheDependencies = ['catalogs', 'catalogs.products', 'some_table'];` + * so a write to any of those tables invalidates this model's caches. List only + * what the cached query loads (`$with`, filter or controller eager loads, counts); + * request `with`/`expand` paths are added automatically. Writes only invalidate + * when the model writing that table uses HasApiModelBehavior or this trait (for + * pivots, a `->using()` pivot class with one), goes through Eloquent events (not + * query builder updates, inserts or quiet saves), or is followed by + * invalidateApiCacheManually(). + * + * @return string[] + */ + public function getApiCacheDependencies(): array + { + return property_exists($this, 'apiCacheDependencies') ? (array) $this->apiCacheDependencies : []; + } + /** * Invalidate all caches for this model. */ diff --git a/src/routes.php b/src/routes.php index fe76c767..4f325624 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) { @@ -129,7 +133,9 @@ function ($router) { $router->get('should-onboard', 'OnboardController@shouldOnboard'); $router->post('create-account', 'OnboardController@createAccount'); $router->post('verify-email', 'OnboardController@verifyEmail'); - $router->post('send-verification-sms', 'OnboardController@sendVerificationSms'); + // Anyone with a signup session can ask for the SMS again; a tight + // per-address limit sits in front of the guard's own budgets. + $router->post('send-verification-sms', 'OnboardController@sendVerificationSms')->middleware(Illuminate\Routing\Middleware\ThrottleRequests::class . ':3,1,onboard-sms'); $router->post('send-verification-email', 'OnboardController@sendVerificationEmail'); } ); @@ -163,6 +169,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 +186,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 +266,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'); @@ -261,6 +280,16 @@ function ($router, $controller) { $router->get('consumers', 'RateLimitController@consumers'); $router->post('consumers/{signature}/reset', 'RateLimitController@resetConsumer'); }); + $router->group(['prefix' => 'sms-guard'], function ($router) { + $router->get('settings', 'SmsGuardController@getSettings'); + $router->post('settings', 'SmsGuardController@saveSettings'); + $router->delete('settings', 'SmsGuardController@resetSettings'); + $router->get('usage', 'SmsGuardController@usage'); + $router->get('ledger', 'SmsGuardController@ledger'); + $router->get('pauses', 'SmsGuardController@pauses'); + $router->post('pauses', 'SmsGuardController@pause'); + $router->delete('pauses/{scope}', 'SmsGuardController@unpause')->where('scope', '.*'); + }); $router->fleetbaseRoutes('schedule-monitor', null, [], function ($router, $controller) { $router->get('tasks', $controller('tasks')); $router->get('{id}/logs', $controller('logs')); diff --git a/tests/Fixtures/Models/Order.php b/tests/Fixtures/Models/Order.php new file mode 100644 index 00000000..569bb607 --- /dev/null +++ b/tests/Fixtures/Models/Order.php @@ -0,0 +1,18 @@ + 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]; + + $container = bind_test_container(array_merge([ + 'database.default' => 'mysql', + 'database.connections.mysql' => $connection, + 'fleetbase.connection.db' => 'mysql', + ], $config)); + $container->instance('cache', new CacheRepository(new ArrayStore())); + Facade::clearResolvedInstances(); + + $capsule = new Capsule($container); + $capsule->addConnection($connection, 'mysql'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + $capsule->getDatabaseManager()->setDefaultConnection('mysql'); + $container->instance('db', $capsule->getDatabaseManager()); + Facade::clearResolvedInstance('db'); + + if ($withLedger) { + self::smsSendsSchema($capsule); + } + + return $capsule; + } + + public static function smsSendsSchema(Capsule $capsule, string $connection = 'mysql'): void + { + $capsule->getConnection($connection)->getSchemaBuilder()->create('sms_sends', function ($table) { + $table->string('uuid')->primary(); + $table->string('company_uuid')->nullable(); + $table->string('tenant_type')->nullable(); + $table->string('tenant_uuid')->nullable(); + $table->string('purpose'); + $table->string('source'); + $table->string('destination')->nullable(); + $table->string('destination_hash')->nullable(); + $table->string('destination_country')->nullable(); + $table->string('destination_type')->nullable(); + $table->string('status'); + $table->string('block_reason')->nullable(); + $table->string('provider')->nullable(); + $table->string('provider_message_id')->nullable(); + $table->text('error')->nullable(); + $table->string('subject_type')->nullable(); + $table->string('subject_uuid')->nullable(); + $table->string('verification_code_uuid')->nullable(); + $table->string('ip_address')->nullable(); + $table->string('user_agent')->nullable(); + $table->text('meta')->nullable(); + $table->timestamps(); + }); + } + + /** + * The verification_codes table as it looks after the October 2026 tracing migration. + */ + public static function verificationCodesSchema(Capsule $capsule, string $connection = 'mysql'): void + { + $capsule->getConnection($connection)->getSchemaBuilder()->create('verification_codes', function ($table) { + $table->string('uuid')->primary(); + $table->string('subject_uuid')->nullable(); + $table->string('subject_type')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('identity')->nullable(); + $table->string('channel')->nullable(); + $table->string('ip_address')->nullable(); + $table->string('user_agent')->nullable(); + $table->string('sms_send_uuid')->nullable(); + $table->string('code')->nullable(); + $table->string('for')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('consumed_at')->nullable(); + $table->text('meta')->nullable(); + $table->string('status')->nullable(); + $table->timestamp('deleted_at')->nullable(); + $table->timestamps(); + }); + } + + public static function companiesSchema(Capsule $capsule, array $rows = [], string $connection = 'mysql'): void + { + $capsule->getConnection($connection)->getSchemaBuilder()->create('companies', function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable(); + $table->string('name')->nullable(); + $table->string('slug')->nullable(); + $table->text('options')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + if ($rows) { + $capsule->getConnection($connection)->table('companies')->insert($rows); + } + } + + public static function settingsSchema(Capsule $capsule, string $connection = 'mysql'): void + { + $capsule->getConnection($connection)->getSchemaBuilder()->create('settings', function ($table) { + $table->increments('id'); + $table->string('key')->unique(); + $table->text('value')->nullable(); + }); + } +} diff --git a/tests/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/Pest.php b/tests/Pest.php index df6f950c..fb334573 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -538,6 +538,12 @@ function bind_test_container(array $config = []): Container $container->make('config')->set($key, $value); } + // The SMS guard is off unless a test turns it on: its counters live in the shared + // cache, so otherwise one file's sends would rate-limit the next file's. + if (!$container->make('config')->has('sms.guard.enabled')) { + $container->make('config')->set('sms.guard.enabled', false); + } + $container->instance('request', Request::create('/int/v1/test', 'GET')); // fresh resource transformer registry per test so registrations never leak between tests diff --git a/tests/Unit/CallProSmsServiceTest.php b/tests/Unit/CallProSmsServiceTest.php index 7d6a678e..d53d68e7 100644 --- a/tests/Unit/CallProSmsServiceTest.php +++ b/tests/Unit/CallProSmsServiceTest.php @@ -50,6 +50,7 @@ function config($key = null, $default = null) 'routing_rules' => [ '+976' => SmsService::PROVIDER_CALLPRO, ], + 'guard' => ['enabled' => false], ], ])); $app->instance('log', new NullLogger()); diff --git a/tests/Unit/Console/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/Console/SmsGuardCommandsTest.php b/tests/Unit/Console/SmsGuardCommandsTest.php new file mode 100644 index 00000000..27b1fb58 --- /dev/null +++ b/tests/Unit/Console/SmsGuardCommandsTest.php @@ -0,0 +1,143 @@ + 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]; + + $container = bind_test_container([ + 'sms.guard' => ['enabled' => true, 'mode' => 'enforce'], + 'database.default' => 'mysql', + 'database.connections.mysql' => $connection, + 'fleetbase.connection.db' => 'mysql', + ]); + $container->instance('cache', new CacheRepository(new ArrayStore())); + app()->forgetInstance(SmsGuard::class); + Facade::clearResolvedInstances(); + + $capsule = new Capsule($container); + $capsule->addConnection($connection, 'mysql'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + $capsule->getDatabaseManager()->setDefaultConnection('mysql'); + $container->instance('db', $capsule->getDatabaseManager()); + Facade::clearResolvedInstance('db'); + + $schema = $capsule->getConnection('mysql')->getSchemaBuilder(); + $schema->create('settings', function ($table) { + $table->increments('id'); + $table->string('key')->unique(); + $table->text('value')->nullable(); + }); + $schema->create('companies', function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable(); + $table->string('name')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + $capsule->getConnection('mysql')->table('companies')->insert([ + ['uuid' => 'company-acme', 'public_id' => 'company_acme', 'name' => 'Acme'], + ]); + + return $capsule; +} + +function sms_guard_command_tester(object $command): CommandTester +{ + $command->setLaravel(app()); + + return new CommandTester($command); +} + +afterEach(function () { + EloquentModel::unsetConnectionResolver(); + EloquentModel::clearBootedModels(); + Facade::clearResolvedInstances(); +}); + +test('sms:mode shows and switches the guard mode at runtime', function () { + sms_guard_commands_database(); + + $show = sms_guard_command_tester(new SmsGuardMode()); + $show->execute([]); + + $switch = sms_guard_command_tester(new SmsGuardMode()); + $switch->execute(['mode' => 'Monitor']); + + $bad = sms_guard_command_tester(new SmsGuardMode()); + $bad->execute(['mode' => 'loud']); + + expect($show->getStatusCode())->toBe(0) + ->and($show->getDisplay())->toContain('Mode: enforce') + ->and($show->getDisplay())->toContain('Pauses: none') + ->and($switch->getStatusCode())->toBe(0) + ->and($switch->getDisplay())->toContain('SMS guard mode set to monitor.') + ->and(SmsPolicy::settings()['mode'])->toBe('monitor') + ->and(SmsPolicy::stored())->toBe(['mode' => 'monitor']) + ->and($bad->getStatusCode())->toBe(1) + ->and($bad->getDisplay())->toContain('Mode must be "enforce" or "monitor".'); +}); + +test('sms:pause and sms:unpause manage a company pause by public id and list it', function () { + sms_guard_commands_database(); + + $pause = sms_guard_command_tester(new SmsGuardPause()); + $pause->execute(['scope' => 'company_acme', '--minutes' => '15', '--reason' => 'incident 42']); + + $listed = sms_guard_command_tester(new SmsGuardMode()); + $listed->execute([]); + + $paused = SmsGuard::make()->breaker()->pausedUntil('company:company-acme'); + + $missing = sms_guard_command_tester(new SmsGuardPause()); + $missing->execute(['scope' => 'company_nobody']); + + $platform = sms_guard_command_tester(new SmsGuardPause()); + $platform->execute(['scope' => 'platform']); + + $unpause = sms_guard_command_tester(new SmsGuardUnpause()); + $unpause->execute(['scope' => 'company-acme']); + + $unpauseMissing = sms_guard_command_tester(new SmsGuardUnpause()); + $unpauseMissing->execute(['scope' => 'company_nobody']); + + expect($pause->getStatusCode())->toBe(0) + ->and($pause->getDisplay())->toContain('SMS paused for company:company-acme until ') + ->and($paused)->not->toBeNull() + ->and($paused['reason'])->toBe('incident 42') + ->and($paused['auto'])->toBeFalse() + ->and($paused['until'])->toBeGreaterThan(time()) + ->and($listed->getDisplay())->toContain('company:company-acme') + ->and($listed->getDisplay())->toContain('incident 42') + ->and($missing->getStatusCode())->toBe(1) + ->and($platform->getStatusCode())->toBe(0) + ->and($platform->getDisplay())->toContain('SMS paused for platform until sms:unpause is run.') + ->and(SmsGuard::make()->breaker()->pausedUntil('platform')['until'])->toBeNull() + ->and($unpause->getStatusCode())->toBe(0) + ->and(SmsGuard::make()->breaker()->pausedUntil('company:company-acme'))->toBeNull() + ->and(SmsPolicy::settings()['pauses'])->not->toHaveKey('company:company-acme') + ->and(SmsPolicy::settings()['pauses'])->toHaveKey('platform') + ->and($unpauseMissing->getStatusCode())->toBe(1) + ->and(SmsGuardPause::companyUuidFromScope('company:abc'))->toBe('abc') + ->and(SmsGuardPause::companyUuidFromScope('platform'))->toBeNull(); +}); diff --git a/tests/Unit/Console/SmsLedgerPruneTest.php b/tests/Unit/Console/SmsLedgerPruneTest.php new file mode 100644 index 00000000..a9ca1c9a --- /dev/null +++ b/tests/Unit/Console/SmsLedgerPruneTest.php @@ -0,0 +1,61 @@ + 90, 'sms.guard.ledger.mask_after_days' => 30]); + Carbon::setTestNow(Carbon::parse('2026-10-13 12:00:00', 'UTC')); + + $context = new SmsContext('driver_login', '+447400100777', 'company-1'); + $old = SmsLedger::record($context, 'sent'); + $gone = SmsLedger::record($context, 'sent'); + SmsSend::where('uuid', $old)->update(['created_at' => Carbon::now()->subDays(40)]); + SmsSend::where('uuid', $gone)->update(['created_at' => Carbon::now()->subDays(100)]); + + $command = new SmsLedgerPrune(); + $command->setLaravel(app()); + $tester = new CommandTester($command); + $tester->execute([]); + $maskedDestination = SmsSend::where('uuid', $old)->value('destination'); + + $explicit = new SmsLedgerPrune(); + $explicit->setLaravel(app()); + $explicitTester = new CommandTester($explicit); + $explicitTester->execute(['--days' => '10', '--mask-after' => '0']); + + expect($tester->getStatusCode())->toBe(0) + ->and($tester->getDisplay())->toContain('masked 1 row(s) older than 30 day(s), deleted 1 row(s) older than 90 day(s)') + ->and($maskedDestination)->toBe('•••0777') + ->and($explicitTester->getStatusCode())->toBe(0) + ->and($explicitTester->getDisplay())->toContain('deleted 1 row(s) older than 10 day(s)') + ->and(SmsSend::count())->toBe(0); +}); + +test('sms:ledger:prune fails cleanly without the ledger table', function () { + $capsule = SmsGuardFixture::database(); + $capsule->getConnection('mysql')->getSchemaBuilder()->drop('sms_sends'); + + $command = new SmsLedgerPrune(); + $command->setLaravel(app()); + $tester = new CommandTester($command); + $tester->execute([]); + + expect($tester->getStatusCode())->toBe(1) + ->and($tester->getDisplay())->toContain('The SMS ledger could not be pruned'); +}); diff --git a/tests/Unit/DatabaseBackupsTest.php b/tests/Unit/DatabaseBackupsTest.php new file mode 100644 index 00000000..1f9ead8c --- /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' => 'example-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' => 'example-db-backups'])); + $service->disk(array_merge($settings, ['disk' => 's3', 'bucket' => null])); + $service->disk(array_merge($settings, ['bucket' => 'ignored'])); + + expect($service->diskConfigs[0]['bucket'])->toBe('example-db-backups') + ->and($service->diskConfigs[1]['bucket'])->toBe('example-media') + ->and($service->diskConfigs[2])->not->toHaveKey('bucket') + ->and($service->objectPath('', 'a.sql.gz'))->toBe('a.sql.gz') + ->and($service->objectPath('/x/y/', 'a.sql.gz'))->toBe('x/y/a.sql.gz'); +}); + +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' => 'example-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/EventsAndExceptionsTest.php b/tests/Unit/EventsAndExceptionsTest.php index e4ccefee..1a1e5274 100644 --- a/tests/Unit/EventsAndExceptionsTest.php +++ b/tests/Unit/EventsAndExceptionsTest.php @@ -20,6 +20,7 @@ use Fleetbase\Exceptions\UnauthorizedRequestException; use Fleetbase\Listeners\SendResourceLifecycleWebhook; use Fleetbase\Models\ChatChannel; +use Fleetbase\Models\ChatMessage; use Fleetbase\Models\ChatParticipant; use Fleetbase\Models\Company; use Fleetbase\Models\Model as FleetbaseModel; @@ -429,6 +430,76 @@ public function getAction(string $key): ?string 'participant removed' => [ChatParticipantRemoved::class, 'chat.removed_participant'], ]); +test('chat participant and message resources expose the participant role and message meta', function () { + bind_test_container(); + + $channel = new ChatChannel(); + $channel->setRawAttributes(['uuid' => 'channel-uuid', 'public_id' => 'chat_channel_public'], true); + + $user = new EventsAndExceptionsUser(); + $user->setRawAttributes(['uuid' => 'user-uuid', 'public_id' => 'user_public', 'name' => 'Corner Cafe'], true); + + $participant = new EventsAndExceptionsChatParticipant(); + $participant->setRawAttributes([ + 'uuid' => 'participant-uuid', + 'public_id' => 'participant_public', + 'chat_channel_uuid' => 'channel-uuid', + 'user_uuid' => 'user-uuid', + 'meta' => '{"role":"store"}', + ], true); + $participant->setRelation('chatChannel', $channel); + $participant->setRelation('user', $user); + + $message = new ChatMessage(); + $message->setRawAttributes([ + 'uuid' => 'message-uuid', + 'public_id' => 'message_public', + 'chat_channel_uuid' => 'channel-uuid', + 'sender_uuid' => 'participant-uuid', + 'content' => 'Drop-off changed to 8 Marina View', + 'meta' => '{"system":true,"kind":"order_updated"}', + ], true); + $message->setRelation('chatChannel', $channel); + $message->setRelation('sender', $participant); + $message->setRelation('attachments', collect()); + $message->setRelation('receipts', collect()); + + // a system message may have no sender at all + $senderless = new ChatMessage(); + $senderless->setRawAttributes(['uuid' => 'message-2', 'public_id' => 'message_2', 'content' => 'Order updated', 'meta' => '{"system":true,"kind":"order_updated"}'], true); + $senderless->setRelation('chatChannel', $channel); + $senderless->setRelation('sender', null); + $senderless->setRelation('attachments', collect()); + $senderless->setRelation('receipts', collect()); + + $plain = new ChatMessage(); + $plain->setRawAttributes(['uuid' => 'message-3', 'public_id' => 'message_3', 'content' => 'Hi'], true); + $plain->setRelation('chatChannel', $channel); + $plain->setRelation('sender', $participant); + $plain->setRelation('attachments', collect()); + $plain->setRelation('receipts', collect()); + + $request = Illuminate\Http\Request::create('/v1/chat-channels/chat_channel_public'); + $request->setRouteResolver(fn () => new EventsAndExceptionsRoute('v1/chat-channels/{id}')); + app()->instance('request', $request); + + $resolve = fn ($resource) => json_decode(json_encode($resource->resolve($request)), true); + $publicMessage = $resolve(new Fleetbase\Http\Resources\ChatMessage($message)); + $publicNoSender = $resolve(new Fleetbase\Http\Resources\ChatMessage($senderless)); + $publicPlain = $resolve(new Fleetbase\Http\Resources\ChatMessage($plain)); + + expect($publicMessage['id'])->toBe('message_public') + ->and($publicMessage['system'])->toBeTrue() + ->and($publicMessage['meta'])->toBe(['system' => true, 'kind' => 'order_updated']) + ->and($publicMessage['sender']['id'])->toBe('participant_public') + ->and($publicMessage['sender']['role'])->toBe('store') + ->and($publicMessage['sender']['meta'])->toBe(['role' => 'store']) + ->and($publicNoSender['sender'])->toBeNull() + ->and($publicNoSender['system'])->toBeTrue() + ->and($publicPlain['system'])->toBeFalse() + ->and($publicPlain['meta'])->toBe([]); +}); + test('resource lifecycle events normalize payload children without dropping chat message relations', function () { bind_test_container(['api.version' => 'v1']); Carbon::setTestNow(Carbon::parse('2026-07-17 12:00:00')); @@ -657,7 +728,6 @@ public function toArray($request): array $channel->setRelation('driverAssigned', $driver); $channel->setAttribute('customer_uuid', 'customer-uuid'); $channel->setAttribute('driver_assigned_uuid', 'driver-assigned-uuid'); - $channel->setAttribute('driverAssigned_uuid', 'driver-assigned-uuid'); $event = EventsAndExceptionsLifecycleEvent::fake([ 'modelName' => 'chat_channel', @@ -699,6 +769,56 @@ public function toArray($request): array ->and($channels)->toContain('chat.33333333-3333-4333-8333-333333333333'); }); +test('resource lifecycle events read relationship uuid channels from the snake case foreign key', function () { + bind_test_container(); + session()->flush(); + session(['company' => 'session-company', 'user' => 'session-user']); + + // Only the real foreign key columns are set, no relations are loaded: the uuid channel must come + // from `driver_assigned_uuid`, not from a `driverAssigned_uuid` attribute that never exists. + $channel = new ChatChannel(); + $channel->setRawAttributes([ + 'uuid' => '44444444-4444-4444-8444-444444444444', + 'public_id' => 'chat_1111111', + 'company_uuid' => 'model-company', + 'created_by_uuid' => 'creator-user', + 'driver_assigned_uuid' => 'driver-uuid-only', + 'customer_uuid' => 'customer-uuid-only', + ], true); + $channel->setRelation('company', null); + + $event = EventsAndExceptionsLifecycleEvent::fake([ + 'modelName' => 'chat_channel', + 'modelClassNamespace' => ChatChannel::class, + 'modelClassName' => 'ChatChannel', + 'modelHumanName' => 'chat channel', + 'modelRecordName' => null, + 'modelUuid' => '44444444-4444-4444-8444-444444444444', + 'namespace' => '\\Fleetbase', + 'version' => 1, + 'eventName' => 'updated', + 'sentAt' => '2026-07-18 12:00:00', + 'eventId' => 'event_driver_channel', + 'apiVersion' => 'v1', + 'requestMethod' => 'PUT', + 'apiCredential' => 'console', + 'apiSecret' => 'internal', + 'apiKey' => null, + 'apiEnvironment' => 'live', + 'isSandbox' => false, + 'data' => [], + 'userSession' => 'session-user', + 'companySession' => 'session-company', + ], $channel); + + $channels = array_map(fn ($channel) => (string) $channel, $event->broadcastOn()); + + expect($channels)->toContain('driverAssigned.driver-uuid-only') + ->and($channels)->toContain('customer.customer-uuid-only') + ->and($channels)->not->toContain('driverAssigned.') + ->and($channels)->not->toContain('customer.'); +}); + test('resource lifecycle events prefer webhook payloads when resources provide them', function () { bind_test_container(['api.version' => 'v1']); diff --git a/tests/Unit/Http/AuthControllerVerificationTest.php b/tests/Unit/Http/AuthControllerVerificationTest.php index 63d1e919..c862e0ed 100644 --- a/tests/Unit/Http/AuthControllerVerificationTest.php +++ b/tests/Unit/Http/AuthControllerVerificationTest.php @@ -533,182 +533,3 @@ function auth_controller_verification_phone_key(string $phone): string ->and($user->last_login)->toBeNull(); } })->with([false, true]); - -test('verify sms code returns success and deletes the consumed verification key', function () { - $redis = auth_controller_verification_fixtures(); - $key = auth_controller_verification_phone_key('+15555550123'); - $redis->values[$key] = '765432'; - - $response = (new AuthController())->verifySmsCode(auth_controller_verification_request([ - 'phone' => '+15555550123', - 'code' => '765432', - ])); - - expect($response->getStatusCode())->toBe(200) - ->and($response->getData(true))->toBe([ - 'status' => 'OK', - 'message' => 'Code verified', - ]) - ->and($redis->deleted)->toBe([$key]) - ->and($redis->values)->not->toHaveKey($key); -}); - -test('verify sms code rejects mismatched codes without deleting the stored code', function () { - $redis = auth_controller_verification_fixtures(); - $key = auth_controller_verification_phone_key('+15555550123'); - $redis->values[$key] = '765432'; - - $response = (new AuthController())->verifySmsCode(auth_controller_verification_request([ - 'phone' => '+15555550123', - 'code' => '000000', - ])); - - expect($response->getStatusCode())->toBe(400) - ->and($response->getData(true))->toBe([ - 'errors' => ['Invalid verification code'], - ]) - ->and($redis->deleted)->toBe([]) - ->and($redis->values[$key])->toBe('765432'); -}); - -test('send verification sms requires a matching user and honors the driver filter', function () { - [$capsule] = auth_controller_verification_database(); - auth_controller_verification_insert_user($capsule, [ - 'type' => 'user', - ]); - - $missingUser = (new AuthController())->sendVerificationSms(auth_controller_verification_request([ - 'phone' => '555550123', - 'countryCode' => '1', - ])); - $wrongType = (new AuthController())->sendVerificationSms(auth_controller_verification_request([ - 'phone' => '5555550123', - 'countryCode' => '1', - 'driver' => 'driver', - ])); - - expect($missingUser->getStatusCode())->toBe(400) - ->and($missingUser->getData(true))->toBe([ - 'errors' => ['No user with this phone # found.'], - ]) - ->and($wrongType->getStatusCode())->toBe(400) - ->and($wrongType->getData(true))->toBe([ - 'errors' => ['No user with this phone # found.'], - ]); -}); - -test('send verification sms stores a ttl backed code and reports twilio failures', function () { - [$capsule, $redis, , $twilio] = auth_controller_verification_database(); - auth_controller_verification_insert_user($capsule, [ - 'type' => 'driver', - ]); - - $success = (new AuthController())->sendVerificationSms(auth_controller_verification_request([ - 'phone' => '5555550123', - 'countryCode' => '1', - 'driver' => 'driver', - ])); - $key = auth_controller_verification_phone_key('+15555550123'); - - $twilio->exception = new RuntimeException('twilio unavailable'); - $failure = (new AuthController())->sendVerificationSms(auth_controller_verification_request([ - 'phone' => '+15555550123', - 'countryCode' => '1', - ])); - - expect($success->getStatusCode())->toBe(200) - ->and($success->getData(true))->toBe(['status' => 'OK']) - ->and($twilio->messages)->toHaveCount(1) - ->and($twilio->messages[0]['to'])->toBe('+15555550123') - ->and($twilio->messages[0]['message'])->toStartWith('Your Fleetbase authentication code is ') - ->and($redis->ttls[$key])->toBe(600) - ->and($redis->values[$key])->toBeInt() - ->and($failure->getStatusCode())->toBe(400) - ->and($failure->getData(true))->toBe(['error' => 'twilio unavailable']); -}); - -test('authenticate sms code rejects invalid otp before querying users or deleting redis state', function () { - $redis = auth_controller_verification_fixtures([ - 'fleetbase.sms_auth_bypass_code' => '246810', - ]); - $key = auth_controller_verification_phone_key('+19765550123'); - $redis->values[$key] = '135790'; - - $response = (new AuthController())->authenticateSmsCode(auth_controller_verification_request([ - 'phone' => '9765550123', - 'countryCode' => '1', - 'code' => '000000', - ])); - - expect($response->getStatusCode())->toBe(400) - ->and($response->getData(true))->toBe([ - 'errors' => ['Invalid verification code'], - ]) - ->and($redis->deleted)->toBe([]) - ->and($redis->values[$key])->toBe('135790'); -}); - -test('authenticate sms code logs in matching users and consumes one time codes', function () { - [$capsule, $redis] = auth_controller_verification_database(); - auth_controller_verification_insert_user($capsule, [ - 'status' => 'active', - ]); - $auth = new AuthControllerVerificationAuthFake(); - Illuminate\Support\Facades\Auth::swap($auth); - - $key = auth_controller_verification_phone_key('+15555550123'); - $redis->values[$key] = '135790'; - - $response = (new AuthController())->authenticateSmsCode(auth_controller_verification_request([ - 'phone' => '5555550123', - 'countryCode' => '1', - 'code' => '135790', - ])); - $payload = $response->getData(true); - - expect($response->getStatusCode())->toBe(200) - ->and($payload['token'])->toContain('|') - ->and($payload['user']['uuid'])->toBe('verification-user') - ->and($auth->loggedIn)->toBe(['verification-user']) - ->and($redis->deleted)->toBe([$key]) - ->and($redis->values)->not->toHaveKey($key); -}); - -test('authenticate sms code reports token creation failures after consuming a valid otp', function () { - [$capsule, $redis] = auth_controller_verification_database(); - auth_controller_verification_insert_user($capsule, [ - 'status' => 'active', - ]); - Illuminate\Support\Facades\Auth::swap(new AuthControllerVerificationAuthFake()); - - $key = auth_controller_verification_phone_key('+15555550123'); - $redis->values[$key] = '135790'; - $capsule->getConnection('mysql')->getSchemaBuilder()->drop('personal_access_tokens'); - - $response = (new AuthController())->authenticateSmsCode(auth_controller_verification_request([ - 'phone' => '5555550123', - 'countryCode' => '1', - 'code' => '135790', - ])); - - expect($response->getStatusCode())->toBe(400) - ->and($response->getData(true)['errors'][0])->toContain('personal_access_tokens') - ->and($redis->deleted)->toBe([$key]) - ->and($redis->values)->not->toHaveKey($key); -}); - -test('authenticate sms code reports authentication failure when a valid otp no longer matches a user', function () { - [, $redis] = auth_controller_verification_database(); - $key = auth_controller_verification_phone_key('+19995550123'); - $redis->values[$key] = '135790'; - - $response = (new AuthController())->authenticateSmsCode(auth_controller_verification_request([ - 'phone' => '9995550123', - 'countryCode' => '1', - 'code' => '135790', - ])); - - expect($response->getStatusCode())->toBe(401) - ->and($response->getData(true))->toBe('Authentication failed') - ->and($redis->deleted)->toBe([$key]); -}); diff --git a/tests/Unit/Http/CommentControllerTest.php b/tests/Unit/Http/CommentControllerTest.php index e2691d0c..0d4b8d78 100644 --- a/tests/Unit/Http/CommentControllerTest.php +++ b/tests/Unit/Http/CommentControllerTest.php @@ -432,3 +432,226 @@ function comment_controller_payload($resource): array ->and($withoutParent->collection->pluck('public_id')->all())->toEqualCanonicalizing(['comment_root', 'comment_other_subject']) ->and($parentScoped->collection->pluck('public_id')->all())->toBe(['comment_reply']); }); + +class CommentVisibilityRoute +{ + public object $controller; + + public function __construct(private string $uri = 'int/v1/comments', private string $method = 'create') + { + $this->controller = new class { + }; + } + + public function getAction(?string $key = null): mixed + { + $action = [ + 'controller' => CommentController::class . '@' . $this->method, + ]; + + return $key ? $action[$key] ?? null : $action; + } + + public function getActionMethod(): string + { + return $this->method; + } + + public function uri(): string + { + return $this->uri; + } +} + +function comment_visibility_database(): Capsule +{ + $capsule = comment_controller_database(); + $capsule->getConnection('mysql')->getSchemaBuilder()->create('orders', function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable()->index(); + $table->string('company_uuid')->nullable()->index(); + $table->timestamps(); + }); + $capsule->getConnection('mysql')->table('orders')->insert([ + ['uuid' => 'order-uuid-1', 'public_id' => 'order_1234567', 'company_uuid' => 'company-1'], + ]); + + return $capsule; +} + +function comment_visibility_use_route(string $uri): void +{ + $request = Request::create('/' . $uri, 'POST'); + $request->setRouteResolver(fn () => new CommentVisibilityRoute($uri)); + Container::getInstance()->instance('request', $request); +} + +function comment_visibility_publish(array $attributes = []): Comment +{ + return Comment::publish(array_merge([ + 'subject_uuid' => 'order-uuid-1', + 'subject_type' => Fleetbase\Tests\Fixtures\Models\Order::class, + 'content' => 'Leave at the side door.', + ], $attributes)); +} + +test('comments on orders default to internal from the console and customer from everywhere else', function () { + comment_visibility_database(); + + comment_visibility_use_route('int/v1/comments'); + $console = comment_visibility_publish(); + + comment_visibility_use_route('storefront/int/v1/orders/order_1234567/notes'); + $storefrontConsole = comment_visibility_publish(); + + comment_visibility_use_route('v1/comments'); + $publicApi = comment_visibility_publish(); + + comment_visibility_use_route('int/v1/comments'); + $explicit = comment_visibility_publish(['visibility' => 'customer']); + $viaMeta = comment_visibility_publish(['meta' => ['visibility' => 'customer', 'pinned' => true]]); + $invalid = comment_visibility_publish(['meta' => ['visibility' => 'everyone']]); + + expect(Comment::find($console->uuid)->meta)->toBe(['visibility' => 'internal']) + ->and($console->isInternal())->toBeTrue() + ->and($storefrontConsole->getVisibility())->toBe('internal') + ->and(Comment::find($publicApi->uuid)->meta)->toBe(['visibility' => 'customer']) + ->and($publicApi->isVisibleToCustomers())->toBeTrue() + ->and(Comment::find($explicit->uuid)->meta)->toBe(['visibility' => 'customer']) + ->and(Comment::find($viaMeta->uuid)->meta)->toBe(['visibility' => 'customer', 'pinned' => true]) + ->and(Comment::find($invalid->uuid)->meta)->toBe(['visibility' => 'internal']); +}); + +test('comments on other subjects keep no visibility unless given and replies follow their parent', function () { + $capsule = comment_visibility_database(); + + comment_visibility_use_route('int/v1/comments'); + $userComment = Comment::publish([ + 'subject_uuid' => 'user-subject', + 'subject_type' => User::class, + 'content' => 'Note on a user', + ]); + $internalRoot = comment_visibility_publish(); + $reply = comment_visibility_publish(['parent_comment_uuid' => $internalRoot->uuid]); + + comment_visibility_use_route('v1/comments'); + $publicReplyToInternal = comment_visibility_publish(['parent_comment_uuid' => $internalRoot->uuid]); + + expect($capsule->getConnection('mysql')->table('comments')->where('uuid', $userComment->uuid)->value('meta'))->toBeNull() + ->and($userComment->getVisibility())->toBe('customer') + ->and($reply->getVisibility())->toBe('internal') + ->and($publicReplyToInternal->getVisibility())->toBe('internal'); +}); + +test('visible to customers scope excludes only internal comments', function () { + $capsule = comment_visibility_database(); + $now = '2026-07-18 00:10:00'; + $capsule->getConnection('mysql')->table('comments')->insert([ + ['id' => 10, 'uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'public_id' => 'comment_internal', 'company_uuid' => 'company-1', 'subject_uuid' => 'order-uuid-1', 'subject_type' => Fleetbase\Tests\Fixtures\Models\Order::class, 'author_uuid' => 'user-author', 'content' => 'Internal', 'meta' => '{"visibility":"internal"}', 'created_at' => $now, 'updated_at' => $now], + ['id' => 11, 'uuid' => 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'public_id' => 'comment_customer', 'company_uuid' => 'company-1', 'subject_uuid' => 'order-uuid-1', 'subject_type' => Fleetbase\Tests\Fixtures\Models\Order::class, 'author_uuid' => 'user-author', 'content' => 'Customer', 'meta' => '{"visibility":"customer"}', 'created_at' => $now, 'updated_at' => $now], + ['id' => 12, 'uuid' => 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', 'public_id' => 'comment_other_meta', 'company_uuid' => 'company-1', 'subject_uuid' => 'order-uuid-1', 'subject_type' => Fleetbase\Tests\Fixtures\Models\Order::class, 'author_uuid' => 'user-author', 'content' => 'Other meta', 'meta' => '{"pinned":true}', 'created_at' => $now, 'updated_at' => $now], + ['id' => 13, 'uuid' => 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', 'public_id' => 'comment_json_null', 'company_uuid' => 'company-1', 'subject_uuid' => 'order-uuid-1', 'subject_type' => Fleetbase\Tests\Fixtures\Models\Order::class, 'author_uuid' => 'user-author', 'content' => 'Json null', 'meta' => 'null', 'created_at' => $now, 'updated_at' => $now], + ]); + + $visible = Comment::query()->without(['author', 'replies'])->visibleToCustomers()->pluck('public_id')->all(); + $internal = Comment::query()->without(['author', 'replies'])->internal()->pluck('public_id')->all(); + + expect($visible)->toEqualCanonicalizing(['comment_root', 'comment_foreign', 'comment_customer', 'comment_other_meta', 'comment_json_null']) + ->and($internal)->toBe(['comment_internal']); +}); + +test('public comment api hides internal comments and internal replies and accepts a visibility', function () { + $capsule = comment_visibility_database(); + $now = '2026-07-18 00:10:00'; + $capsule->getConnection('mysql')->table('comments')->insert([ + ['id' => 10, 'uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'public_id' => 'comment_internal', 'company_uuid' => 'company-1', 'subject_uuid' => 'user-subject', 'subject_type' => '\\' . User::class, 'author_uuid' => 'user-author', 'parent_comment_uuid' => null, 'content' => 'Internal', 'meta' => '{"visibility":"internal"}', 'created_at' => $now, 'updated_at' => $now], + ['id' => 11, 'uuid' => 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'public_id' => 'comment_internal_reply', 'company_uuid' => 'company-1', 'subject_uuid' => 'user-subject', 'subject_type' => '\\' . User::class, 'author_uuid' => 'user-author', 'parent_comment_uuid' => '11111111-1111-4111-8111-111111111111', 'content' => 'Internal reply', 'meta' => '{"visibility":"internal"}', 'created_at' => $now, 'updated_at' => $now], + ['id' => 12, 'uuid' => 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', 'public_id' => 'comment_customer_reply', 'company_uuid' => 'company-1', 'subject_uuid' => 'user-subject', 'subject_type' => '\\' . User::class, 'author_uuid' => 'user-author', 'parent_comment_uuid' => '11111111-1111-4111-8111-111111111111', 'content' => 'Customer reply', 'meta' => '{"visibility":"customer"}', 'created_at' => $now, 'updated_at' => $now], + ]); + + $listed = comment_controller()->query(comment_controller_query_request(['limit' => -1, 'without_parent' => '1'])); + $hidden = comment_controller()->find('comment_internal'); + + // resources resolve replies against the current request: a public route hides internal replies + comment_visibility_use_route('v1/comments'); + $root = collect($listed->collection)->firstWhere('public_id', 'comment_root'); + $publicReplies = collect((new Fleetbase\Http\Resources\Comment($root->resource))->resolve(request())['replies'])->map(fn ($reply) => $reply->resource->public_id)->all(); + + comment_visibility_use_route('int/v1/comments'); + $internalReplies = collect((new Fleetbase\Http\Resources\Comment($root->resource))->resolve(request())['replies'])->map(fn ($reply) => $reply->resource->public_id)->all(); + + $created = comment_controller()->create(comment_controller_create_request([ + 'subject' => ['id' => 'user_subject', 'type' => 'user'], + 'content' => 'Only for the team', + 'visibility' => 'internal', + ])); + + expect($listed->collection->pluck('public_id')->all())->not->toContain('comment_internal') + ->and($listed->collection->pluck('public_id')->all())->toContain('comment_root') + ->and($hidden->getStatusCode())->toBe(404) + ->and($publicReplies)->toBe(['comment_customer_reply']) + ->and($internalReplies)->toEqualCanonicalizing(['comment_customer_reply', 'comment_internal_reply']) + ->and($created->resource->getVisibility())->toBe('internal') + ->and((new CreateCommentRequest())->rules()['visibility'])->toContain('nullable'); +}); + +test('comment created broadcasts internal notes to the company only and customer comments to the subject too', function () { + comment_visibility_database(); + + comment_visibility_use_route('int/v1/comments'); + $internal = comment_visibility_publish(); + + comment_visibility_use_route('v1/comments'); + $customer = comment_visibility_publish(); + $unknown = Comment::publish(['subject_uuid' => 'missing-order', 'subject_type' => Fleetbase\Tests\Fixtures\Models\Order::class, 'content' => 'Lost', 'visibility' => 'customer']); + + $channels = fn (Comment $comment) => array_map(fn ($channel) => (string) $channel, (new Fleetbase\Events\CommentCreated($comment))->broadcastOn()); + + $event = new Fleetbase\Events\CommentCreated($customer); + $payload = $event->broadcastWith(); + + expect($event->broadcastAs())->toBe('comment.created') + ->and($channels($internal))->toBe(['company.company-1']) + ->and($channels($customer))->toBe(['company.company-1', 'order.order_1234567', 'order.order-uuid-1']) + ->and($channels($unknown))->toBe(['company.company-1']) + ->and($payload['event'])->toBe('comment.created') + ->and($payload['subject_id'])->toBe('order_1234567') + ->and($payload['data']['content'])->toBe('Leave at the side door.') + ->and($payload['data']['visibility'])->toBe('customer'); +}); + +test('creating a comment dispatches comment created and never fails when broadcasting does', function () { + comment_visibility_database(); + + $dispatched = new ArrayObject(); + Container::getInstance()->instance('events', new class($dispatched) { + public function __construct(private ArrayObject $dispatched) + { + } + + public function dispatch($event, $payload = [], $halt = false) + { + $this->dispatched->append($event); + + return null; + } + }); + Facade::clearResolvedInstance('events'); + + comment_visibility_use_route('v1/comments'); + $comment = comment_visibility_publish(); + + Container::getInstance()->instance('events', new class { + public function dispatch($event, $payload = [], $halt = false) + { + throw new RuntimeException('socket server unavailable'); + } + }); + Facade::clearResolvedInstance('events'); + $stillCreated = comment_visibility_publish(['content' => 'Second note']); + + expect($dispatched)->toHaveCount(1) + ->and($dispatched[0])->toBeInstanceOf(Fleetbase\Events\CommentCreated::class) + ->and($dispatched[0]->comment->is($comment))->toBeTrue() + ->and($stillCreated->exists)->toBeTrue(); +}); diff --git a/tests/Unit/Http/ConcreteFilterContractsTest.php b/tests/Unit/Http/ConcreteFilterContractsTest.php index 72f85db6..f69fb939 100644 --- a/tests/Unit/Http/ConcreteFilterContractsTest.php +++ b/tests/Unit/Http/ConcreteFilterContractsTest.php @@ -18,6 +18,7 @@ use Fleetbase\Http\Filter\NotificationFilter; use Fleetbase\Http\Filter\PermissionFilter; use Fleetbase\Http\Filter\PolicyFilter; +use Fleetbase\Http\Filter\ReportFilter; use Fleetbase\Http\Filter\RoleFilter; use Fleetbase\Http\Filter\ScheduleExceptionFilter; use Fleetbase\Http\Filter\ScheduleFilter; @@ -40,12 +41,14 @@ use Fleetbase\Models\Group; use Fleetbase\Models\Notification; use Fleetbase\Models\Policy; +use Fleetbase\Models\Report; use Fleetbase\Models\Role; use Fleetbase\Models\ScheduleException; use Fleetbase\Models\ScheduleItem; use Fleetbase\Models\ScheduleTemplate; use Fleetbase\Models\User; use Fleetbase\Models\WebhookEndpoint; +use Fleetbase\Support\Find; use Illuminate\Database\Capsule\Manager as Capsule; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Database\Eloquent\Model as EloquentModel; @@ -1011,6 +1014,30 @@ public function isAdmin(): bool ->and($logBuilder->queries)->toBe(['delivered']); }); +test('report filter scopes saved reports to the session company', function () { + $capsule = concrete_filter_database(); + $schema = $capsule->getConnection('mysql')->getSchemaBuilder(); + $schema->dropIfExists('reports'); + $schema->create('reports', function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('title')->nullable(); + $table->string('type')->nullable(); + $table->softDeletes(); + }); + $capsule->getConnection('mysql')->table('reports')->insert([ + ['uuid' => 'report-visible', 'public_id' => 'report_visible', 'company_uuid' => 'company-1', 'title' => 'Mine', 'type' => 'custom'], + ['uuid' => 'report-hidden', 'public_id' => 'report_hidden', 'company_uuid' => 'company-2', 'title' => 'Theirs', 'type' => 'custom'], + ['uuid' => 'report-orphan', 'public_id' => 'report_orphan', 'company_uuid' => null, 'title' => 'Orphan', 'type' => 'custom'], + ]); + + expect(Find::httpFilterForModel(new Report()))->toBe('\\Fleetbase\\Http\\Filter\\ReportFilter') + ->and(concrete_filter_uuids(ReportFilter::class, Report::class, [], 'int/v1/reports'))->toBe(['report-visible']) + ->and(concrete_filter_uuids(ReportFilter::class, Report::class, ['type' => 'custom'], 'int/v1/reports'))->toBe(['report-visible']) + ->and(concrete_filter_uuids(ReportFilter::class, Report::class, [], 'v1/reports'))->toBe(['report-visible']); +}); + test('category filter applies tenant core parent and list filters', function () { $capsule = concrete_filter_database(); $parentUuid = '11111111-1111-4111-8111-111111111111'; diff --git a/tests/Unit/Http/MiddlewareContractsTest.php b/tests/Unit/Http/MiddlewareContractsTest.php index 952a0f49..5da56557 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(); @@ -642,15 +660,27 @@ function middleware_contracts_log_middleware(bool $enabled = true): LogApiReques middleware_contracts_mutation_request('/int/v1/orders', 'int/v1/orders'), fn () => new JsonResponse(['id' => 'order_2'], 201) ); + $storefrontInternal = middleware_contracts_log_middleware()->handle( + middleware_contracts_mutation_request('/storefront/int/v1/orders', 'storefront/int/v1/orders'), + fn () => new JsonResponse(['id' => 'order_3'], 201) + ); $excluded = middleware_contracts_log_middleware()->handle( middleware_contracts_mutation_request('/v1/drivers/driver_1/track', 'v1/drivers/{driver}/track'), fn () => new JsonResponse(['id' => 'track_1'], 201) ); + $unroutedRequest = middleware_contracts_mutation_request('/v1/orders', 'v1/orders'); + $unroutedRequest->setRouteResolver(fn () => null); + $unrouted = middleware_contracts_log_middleware()->handle( + $unroutedRequest, + fn () => new JsonResponse(['id' => 'order_4'], 201) + ); expect($disabled->getStatusCode())->toBe(201) ->and($read->getStatusCode())->toBe(204) ->and($internal->getStatusCode())->toBe(201) + ->and($storefrontInternal->getStatusCode())->toBe(201) ->and($excluded->getStatusCode())->toBe(201) + ->and($unrouted->getStatusCode())->toBe(201) ->and($bus->jobs)->toBeEmpty(); }); @@ -694,6 +724,38 @@ function middleware_contracts_log_middleware(bool $enabled = true): LogApiReques ]); }); + test('log api requests dispatches extension mutation request logs with related ids from the request', function () { + middleware_contracts_fixture(['api.version' => 'v1']); + session([ + 'company' => 'company-1', + 'api_credential' => 'store_live_key', + ]); + $bus = new MiddlewareContractsBusFake(); + app()->instance('bus', $bus); + app()->instance(BusDispatcher::class, $bus); + Facade::clearResolvedInstance('bus'); + Facade::clearResolvedInstance(BusDispatcher::class); + + $request = middleware_contracts_mutation_request('/storefront/v1/customers/request-creation-code', 'storefront/v1/customers/request-creation-code'); + $request->attributes->set(Fleetbase\Jobs\LogApiRequest::RELATED_ATTRIBUTE, ['store_abc123']); + + $response = middleware_contracts_log_middleware()->handle( + $request, + fn () => new JsonResponse(['status' => 'ok'], 200) + ); + + expect($response->getStatusCode())->toBe(200) + ->and($bus->jobs)->toHaveCount(1) + ->and($bus->jobs[0])->toBeInstanceOf(Fleetbase\Jobs\LogApiRequest::class) + ->and($bus->jobs[0]->payload)->toMatchArray([ + 'company_uuid' => 'company-1', + 'method' => 'POST', + 'path' => 'storefront/v1/customers/request-creation-code', + 'status_code' => 200, + 'related' => ['store_abc123'], + ]); + }); + test('log api requests swallows dispatch errors and keeps original response', function () { middleware_contracts_fixture(['api.version' => 'v1']); $bus = new MiddlewareContractsBusFake(); diff --git a/tests/Unit/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/SmsGuardControllerTest.php b/tests/Unit/Http/SmsGuardControllerTest.php new file mode 100644 index 00000000..94f0944b --- /dev/null +++ b/tests/Unit/Http/SmsGuardControllerTest.php @@ -0,0 +1,215 @@ + 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]; + + $container = bind_test_container([ + 'sms.guard' => ['enabled' => true, 'mode' => 'enforce', 'limits' => ['company' => ['per_day' => 200]]], + 'database.default' => 'mysql', + 'database.connections.mysql' => $connection, + 'fleetbase.connection.db' => 'mysql', + ]); + $container->instance('cache', new CacheRepository(new ArrayStore())); + app()->forgetInstance(SmsGuard::class); + Facade::clearResolvedInstances(); + + $capsule = new Capsule($container); + $capsule->addConnection($connection, 'mysql'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + $capsule->getDatabaseManager()->setDefaultConnection('mysql'); + $container->instance('db', $capsule->getDatabaseManager()); + Facade::clearResolvedInstance('db'); + + $schema = $capsule->getConnection('mysql')->getSchemaBuilder(); + $schema->create('settings', function ($table) { + $table->increments('id'); + $table->string('key')->unique(); + $table->text('value')->nullable(); + }); + $schema->create('companies', function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable(); + $table->string('name')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + SmsGuardFixture::smsSendsSchema($capsule); + $capsule->getConnection('mysql')->table('companies')->insert([ + ['uuid' => 'company-acme', 'public_id' => 'company_acme', 'name' => 'Acme'], + ['uuid' => 'company-globex', 'public_id' => 'company_globex', 'name' => 'Globex'], + ]); + + $validation = new ValidationFactory(new Translator(new ArrayLoader(), 'en')); + $validation->setPresenceVerifier(new DatabasePresenceVerifier($capsule->getDatabaseManager())); + Request::macro('validate', function (array $rules) use ($validation) { + return $validation->make($this->all(), $rules)->validate(); + }); + + return $capsule; +} + +function sms_guard_controller_request(string $method = 'GET', array $input = []): AdminRequest +{ + return AdminRequest::create('/int/v1/sms-guard/settings', $method, $input); +} + +afterEach(function () { + Carbon::setTestNow(); + + $macros = new ReflectionProperty(Request::class, 'macros'); + $macros->setAccessible(true); + $macros->setValue(null, array_diff_key($macros->getValue(), ['validate' => true])); + + EloquentModel::unsetConnectionResolver(); + EloquentModel::clearBootedModels(); + Facade::clearResolvedInstances(); +}); + +test('sms guard controller returns the effective policy, defaults, pauses and ledger availability', function () { + sms_guard_controller_fixture(); + SmsPolicy::update(['mode' => 'monitor', 'overrides' => [['company_uuid' => 'company-acme', 'per_day' => 2000, 'note' => 'large client']]]); + SmsGuard::make()->breaker()->pause('company:company-globex', 30, 'incident'); + + $payload = (new SmsGuardController())->getSettings(sms_guard_controller_request())->getData(true); + + expect($payload['settings']['mode'])->toBe('monitor') + ->and($payload['settings']['limits']['company']['per_day'])->toBe(200) + ->and($payload['settings']['overrides'][0])->toMatchArray(['company_uuid' => 'company-acme', 'per_day' => 2000, 'note' => 'large client', 'company_id' => 'company_acme', 'company_name' => 'Acme']) + ->and($payload['defaults']['mode'])->toBe('enforce') + ->and($payload['pauses'])->toHaveCount(1) + ->and($payload['pauses'][0])->toMatchArray(['scope' => 'company:company-globex', 'reason' => 'incident', 'company_name' => 'Globex', 'platform' => false]) + ->and($payload['pauses'][0]['until_iso'])->toBeString() + ->and($payload['ledger_available'])->toBeTrue(); +}); + +test('sms guard controller saves a validated policy, replaces the override list and keeps pauses', function () { + sms_guard_controller_fixture(); + SmsGuard::make()->breaker()->pause('platform', null, 'freeze'); + + $saved = (new SmsGuardController())->saveSettings(sms_guard_controller_request('POST', [ + 'enabled' => true, + 'mode' => 'enforce', + 'alert_emails' => ['ops@example.test'], + 'validation' => ['enabled' => true, 'blocked_countries' => ['KP'], 'blocked_prefixes' => ['+881']], + 'limits' => ['company' => ['per_day' => 300, 'per_hour' => null], 'destination' => ['cooldown_seconds' => 30]], + 'breaker' => ['pause_minutes' => 15, 'alert_percentages' => [50], 'new_country_min_sends' => 5], + 'overrides' => [['company_uuid' => 'company-globex', 'unlimited' => true, 'note' => 'partner']], + ]))->getData(true); + + $trimmed = (new SmsGuardController())->saveSettings(sms_guard_controller_request('POST', ['enabled' => true, 'mode' => 'enforce', 'overrides' => []]))->getData(true); + + expect($saved['settings']['alert_emails'])->toBe(['ops@example.test']) + ->and($saved['settings']['validation']['blocked_countries'])->toBe(['KP']) + ->and($saved['settings']['limits']['company'])->toBe(['per_minute' => 10, 'per_hour' => null, 'per_day' => 300]) + ->and($saved['settings']['limits']['destination']['cooldown_seconds'])->toBe(30) + ->and($saved['settings']['breaker']['pause_minutes'])->toBe(15) + ->and($saved['settings']['breaker']['new_country_min_sends'])->toBe(5) + ->and($saved['settings']['overrides'][0])->toMatchArray(['company_uuid' => 'company-globex', 'unlimited' => true, 'company_name' => 'Globex']) + ->and($saved['pauses'][0]['scope'])->toBe('platform') + ->and($trimmed['settings']['overrides'])->toBe([]) + ->and($trimmed['settings']['limits']['company']['per_day'])->toBe(300, 'a save without limits keeps the stored ones') + ->and($trimmed['pauses'][0]['scope'])->toBe('platform', 'saving never drops a pause'); +}); + +test('sms guard controller rejects an unknown organization, a bad mode and a bad email', function () { + sms_guard_controller_fixture(); + + $cases = [ + ['enabled' => true, 'mode' => 'enforce', 'overrides' => [['company_uuid' => 'company-missing', 'per_day' => 5]]], + ['enabled' => true, 'mode' => 'loud'], + ['enabled' => true, 'mode' => 'monitor', 'alert_emails' => ['not-an-email']], + ]; + + foreach ($cases as $input) { + try { + (new SmsGuardController())->saveSettings(sms_guard_controller_request('POST', $input)); + expect(false)->toBeTrue('validation should have failed'); + } catch (ValidationException $e) { + expect($e->errors())->not->toBeEmpty(); + } + } +}); + +test('sms guard controller resets to the defaults and manages pauses through the api', function () { + sms_guard_controller_fixture(); + $controller = new SmsGuardController(); + + $paused = $controller->pause(sms_guard_controller_request('POST', ['scope' => 'company_acme', 'minutes' => 15, 'reason' => 'incident 7']))->getData(true); + $bad = $controller->pause(sms_guard_controller_request('POST', ['scope' => 'company_nobody'])); + $listed = $controller->pauses(sms_guard_controller_request())->getData(true); + $lifted = $controller->unpause(sms_guard_controller_request('DELETE'), 'company%3Acompany-acme')->getData(true); + $badLift = $controller->unpause(sms_guard_controller_request('DELETE'), 'company_nobody'); + + SmsPolicy::update(['mode' => 'monitor']); + $reset = $controller->resetSettings(sms_guard_controller_request('DELETE'))->getData(true); + + expect($paused['pauses'][0])->toMatchArray(['scope' => 'company:company-acme', 'reason' => 'incident 7', 'company_id' => 'company_acme', 'auto' => false]) + ->and($bad->getStatusCode())->toBe(422) + ->and($listed['pauses'])->toHaveCount(1) + ->and($lifted['pauses'])->toBe([]) + ->and($badLift->getStatusCode())->toBe(422) + ->and($reset['settings']['mode'])->toBe('enforce') + ->and(SmsPolicy::stored())->toBe([]); +}); + +test('sms guard controller summarises usage per organization and filters the ledger', function () { + sms_guard_controller_fixture(); + Carbon::setTestNow(Carbon::parse('2026-10-13 12:00:00', 'UTC')); + + $acme = new SmsContext('storefront_create_customer', '+447400100001', 'company-acme', 'store', 'store-1', 'storefront', '203.0.113.9'); + $globex = new SmsContext('driver_login', '+447400100002', 'company-globex', null, null, 'api', '198.51.100.7'); + + SmsLedger::update(SmsLedger::record($acme, 'reserved'), ['status' => 'sent']); + SmsLedger::update(SmsLedger::record($acme, 'reserved'), ['status' => 'sent']); + SmsLedger::record($acme, 'blocked', ['block_reason' => 'cooldown']); + SmsLedger::update(SmsLedger::record($globex, 'reserved'), ['status' => 'failed', 'error' => 'carrier']); + SmsLedger::record(new SmsContext('2fa', '+447400100003', null), 'reserved'); + SmsPolicy::update(['overrides' => [['company_uuid' => 'company-globex', 'unlimited' => true]]]); + SmsGuard::make()->breaker()->pause('company:company-acme', 5, 'test'); + + $usage = (new SmsGuardController())->usage(sms_guard_controller_request('GET', ['hours' => 1]))->getData(true); + $ledger = (new SmsGuardController())->ledger(sms_guard_controller_request('GET', ['status' => 'blocked']))->getData(true); + $byNumber = (new SmsGuardController())->ledger(sms_guard_controller_request('GET', ['destination' => '+44 7400 100002', 'company_uuid' => 'company-globex', 'purpose' => 'driver_login']))->getData(true); + + expect($usage['hours'])->toBe(1) + ->and($usage['ledger_available'])->toBeTrue() + ->and($usage['usage'])->toHaveCount(2) + ->and($usage['usage'][0])->toMatchArray(['company_uuid' => 'company-acme', 'company_name' => 'Acme', 'sent' => 2, 'failed' => 0, 'blocked' => 1, 'destinations' => 1, 'daily_budget' => 200, 'paused' => true]) + ->and($usage['usage'][1])->toMatchArray(['company_uuid' => 'company-globex', 'sent' => 0, 'failed' => 1, 'daily_budget' => null, 'paused' => false]) + ->and($ledger['sends'])->toHaveCount(1) + ->and($ledger['sends'][0]['block_reason'])->toBe('cooldown') + ->and($byNumber['sends'])->toHaveCount(1) + ->and($byNumber['sends'][0]['error'])->toBe('carrier') + ->and(SmsSend::count())->toBe(5); +}); diff --git a/tests/Unit/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/Jobs/LogApiRequestTest.php b/tests/Unit/Jobs/LogApiRequestTest.php index 1bcdc8ea..eb55e01b 100644 --- a/tests/Unit/Jobs/LogApiRequestTest.php +++ b/tests/Unit/Jobs/LogApiRequestTest.php @@ -224,6 +224,30 @@ function loggable_request(float $startedAt): Request ->and($payload)->not->toHaveKeys(['api_credential_uuid', 'access_token_id']); }); + it('adds ids that middleware related to the request to the payload', function () { + bind_test_container(['api.version' => 'v1']); + session([ + 'api_credential' => 'store_live_key', + 'company' => 'company-1', + ]); + + $related = loggable_request(microtime(true) - 0.01); + $related->attributes->set(LogApiRequest::RELATED_ATTRIBUTE, ['store_abc123', null, '']); + + $single = loggable_request(microtime(true) - 0.01); + $single->attributes->set(LogApiRequest::RELATED_ATTRIBUTE, 'network_xyz789'); + + $relatedPayload = LogApiRequest::getPayload($related, new JsonResponse(['id' => 'customer_1'], 201)); + $singlePayload = LogApiRequest::getPayload($single, new JsonResponse(['status' => 'ok'], 200)); + $plainPayload = LogApiRequest::getPayload(loggable_request(microtime(true) - 0.01), new JsonResponse(['status' => 'ok'], 200)); + + expect($relatedPayload['related'])->toBe(['customer_1', 'store_abc123']) + ->and($relatedPayload['company_uuid'])->toBe('company-1') + ->and($relatedPayload)->not->toHaveKeys(['api_credential_uuid', 'access_token_id']) + ->and($singlePayload['related'])->toBe(['network_xyz789']) + ->and($plainPayload['related'])->toBe([]); + }); + it('persists API request logs on the selected connection and clears response cache', function () { $database = log_api_request_database(); $cache = new LogApiRequestResponseCacheFake(); diff --git a/tests/Unit/Models/ChatModelsTest.php b/tests/Unit/Models/ChatModelsTest.php index 2e39b922..2a0cb2d9 100644 --- a/tests/Unit/Models/ChatModelsTest.php +++ b/tests/Unit/Models/ChatModelsTest.php @@ -542,3 +542,50 @@ function chat_participant_with_name(string $uuid, ?string $name): ChatParticipan ->and($channel->logs()->getRelated())->toBeInstanceOf(ChatLog::class) ->and($channel->logs()->getForeignKeyName())->toBe('chat_channel_uuid'); }); + +it('adds a nullable meta column for participant roles and system messages', function () { + $capsule = chat_models_database(); + $connection = $capsule->getConnection('testing'); + $schema = $connection->getSchemaBuilder(); + app()->instance('db.schema', $schema); + Facade::clearResolvedInstance('db.schema'); + + $migration = require __DIR__ . '/../../../migrations/2026_10_13_000000_add_meta_to_chat_participants_and_chat_messages_tables.php'; + $migration->up(); + // guarded: running it twice is a no-op + $migration->up(); + + $now = '2026-07-18 12:00:00'; + $connection->table('chat_channels')->insert(['uuid' => 'channel-1', 'public_id' => 'chat_1', 'created_at' => $now, 'updated_at' => $now]); + $connection->table('chat_participants')->insert([ + ['uuid' => 'participant-store', 'chat_channel_uuid' => 'channel-1', 'user_uuid' => 'user-store', 'meta' => '{"role":"store"}', 'created_at' => $now, 'updated_at' => $now], + ['uuid' => 'participant-legacy', 'chat_channel_uuid' => 'channel-1', 'user_uuid' => 'user-legacy', 'meta' => null, 'created_at' => $now, 'updated_at' => $now], + ['uuid' => 'participant-odd', 'chat_channel_uuid' => 'channel-1', 'user_uuid' => 'user-odd', 'meta' => '{"role":"admin"}', 'created_at' => $now, 'updated_at' => $now], + ]); + $connection->table('chat_messages')->insert([ + ['uuid' => 'message-system', 'chat_channel_uuid' => 'channel-1', 'sender_uuid' => 'participant-store', 'content' => 'Drop-off changed', 'meta' => '{"system":true,"kind":"order_updated"}', 'created_at' => $now, 'updated_at' => $now], + ['uuid' => 'message-plain', 'chat_channel_uuid' => 'channel-1', 'sender_uuid' => 'participant-legacy', 'content' => 'Hello', 'meta' => null, 'created_at' => $now, 'updated_at' => $now], + ]); + + $store = ChatParticipant::query()->without('user')->whereKey('participant-store')->firstOrFail(); + $legacy = ChatParticipant::query()->without('user')->whereKey('participant-legacy')->firstOrFail(); + $odd = ChatParticipant::query()->without('user')->whereKey('participant-odd')->firstOrFail(); + $system = ChatMessage::query()->without(['sender', 'attachments', 'receipts'])->whereKey('message-system')->firstOrFail(); + $plain = ChatMessage::query()->without(['sender', 'attachments', 'receipts'])->whereKey('message-plain')->firstOrFail(); + + $migration->down(); + + expect($store->meta)->toBe(['role' => 'store']) + ->and($store->getRole())->toBe(ChatParticipant::ROLE_STORE) + ->and($legacy->getRole())->toBeNull() + ->and($odd->getRole())->toBeNull() + ->and(ChatParticipant::ROLES)->toBe(['customer', 'driver', 'store', 'system']) + ->and($system->isSystem())->toBeTrue() + ->and($system->getSystemKind())->toBe('order_updated') + ->and($plain->isSystem())->toBeFalse() + ->and($plain->getSystemKind())->toBeNull() + ->and((new ChatParticipant())->isFillable('meta'))->toBeTrue() + ->and((new ChatMessage())->isFillable('meta'))->toBeTrue() + ->and($schema->hasColumn('chat_participants', 'meta'))->toBeFalse() + ->and($schema->hasColumn('chat_messages', 'meta'))->toBeFalse(); +}); diff --git a/tests/Unit/Models/FileModelTest.php b/tests/Unit/Models/FileModelTest.php index 6a89779c..3e416eac 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' => 'example-media', + 'filesystems.disks.s3.url' => 'https://media.example.test/assets', + ]); + + expect(File::s3KeyFromUrl('https://example-media.s3.amazonaws.com/uploads/a/photo.png'))->toBe('uploads/a/photo.png') + ->and(File::s3KeyFromUrl('https://example-media.s3.ap-southeast-1.amazonaws.com/uploads/a/photo.png?X-Amz-Signature=old'))->toBe('uploads/a/photo.png') + ->and(File::s3KeyFromUrl('https://example-media.s3-ap-southeast-1.amazonaws.com/custom-avatars/vehicles/c/My%20Van.png'))->toBe('custom-avatars/vehicles/c/My Van.png') + ->and(File::s3KeyFromUrl('https://s3.ap-southeast-1.amazonaws.com/example-media/uploads/b/logo.png'))->toBe('uploads/b/logo.png') + ->and(File::s3KeyFromUrl('https://media.example.test/assets/uploads/c/doc.pdf'))->toBe('uploads/c/doc.pdf') + // other buckets, other hosts and non-urls are left alone + ->and(File::s3KeyFromUrl('https://flb-assets.s3.ap-southeast-1.amazonaws.com/static/no-avatar.png'))->toBeNull() + ->and(File::s3KeyFromUrl('https://s3.ap-southeast-1.amazonaws.com/flb-assets/static/no-avatar.png'))->toBeNull() + ->and(File::s3KeyFromUrl('https://evil.example.test/example-media.s3.amazonaws.com/x.png'))->toBeNull() + ->and(File::s3KeyFromUrl('https://example-media.s3.amazonaws.com.evil.test/x.png'))->toBeNull() + ->and(File::s3KeyFromUrl('https://example-media.s3.amazonaws.com/'))->toBeNull() + ->and(File::s3KeyFromUrl('5f1c2a10-0000-4000-8000-000000000000'))->toBeNull() + ->and(File::s3KeyFromUrl(null))->toBeNull(); + + // config is shared across tests: drop the url override here as well + bind_file_model_filesystem(['filesystems.disks.s3.bucket' => null, 'filesystems.disks.s3.url' => null]); + + expect(File::s3KeyFromUrl('https://example-media.s3.amazonaws.com/uploads/a/photo.png'))->toBeNull(); +}); + +it('re-signs stored bucket urls and leaves every other value unchanged', function () { + $filesystem = bind_file_model_filesystem([ + 'filesystems.disks.s3.bucket' => 'example-media', + ]); + + $stored = 'https://example-media.s3.ap-southeast-1.amazonaws.com/custom-avatars/vehicles/c/van.png?X-Amz-Expires=7200&X-Amz-Signature=expired'; + + expect(File::signStoredUrl($stored))->toBe('https://s3.example.test/custom-avatars/vehicles/c/van.png?temporary=1') + ->and($filesystem->disk('s3')->temporaryUrls)->toHaveKey('custom-avatars/vehicles/c/van.png') + ->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/OperationalModelsTest.php b/tests/Unit/Models/OperationalModelsTest.php index 4978d81d..af164fe8 100644 --- a/tests/Unit/Models/OperationalModelsTest.php +++ b/tests/Unit/Models/OperationalModelsTest.php @@ -636,6 +636,21 @@ public function clear(): bool Carbon::setTestNow(); }); +it('leaves models guarded when publishing a comment fails', function () { + operational_models_database(); + session([ + 'user' => 'user-1', + 'company' => 'company-1', + ]); + + // A duplicate primary key makes the insert throw. + Comment::publish(['uuid' => 'comment-dup', 'content' => 'First', 'subject_uuid' => 'order-1', 'subject_type' => User::class]); + + expect(fn () => Comment::publish(['uuid' => 'comment-dup', 'content' => 'Second', 'subject_uuid' => 'order-1', 'subject_type' => User::class])) + ->toThrow(Exception::class) + ->and(Comment::isUnguarded())->toBeFalse(); +}); + it('defines comment company parent and replies relationship contracts', function () { operational_models_database(); diff --git a/tests/Unit/Models/TemplateModelsTest.php b/tests/Unit/Models/TemplateModelsTest.php index d0a221fb..1c3a940e 100644 --- a/tests/Unit/Models/TemplateModelsTest.php +++ b/tests/Unit/Models/TemplateModelsTest.php @@ -5,6 +5,7 @@ use Fleetbase\Traits\Searchable; use Illuminate\Database\Capsule\Manager as Capsule; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Events\Dispatcher; use Illuminate\Support\Facades\Facade; @@ -15,10 +16,19 @@ class TemplateQueryTenantFixture extends Model protected $guarded = []; public $timestamps = false; - public function notes() + public static int $archived = 0; + + public function notes(): HasMany { return $this->hasMany(TemplateQueryTenantNoteFixture::class, 'fixture_id'); } + + public function archive() + { + static::$archived++; + + return $this; + } } class TemplateQueryTenantNoteFixture extends Model @@ -411,16 +421,18 @@ function template_models_database(): Capsule ['field' => 'archived_at', 'operator' => 'null'], ['field' => null, 'operator' => '=', 'value' => 'ignored'], ], - 'with' => ['notes'], + 'with' => ['notes', 'archive'], 'sort' => [ ['field' => 'score', 'direction' => 'desc'], ], 'limit' => 1, ]); - $results = $query->execute(); + TemplateQueryTenantFixture::$archived = 0; + $results = $query->execute(); expect($results)->toHaveCount(1) + ->and(TemplateQueryTenantFixture::$archived)->toBe(0) ->and($results->first()->name)->toBe('Alpha order') ->and($results->first()->company_uuid)->toBe('company-1') ->and($results->first()->relationLoaded('notes'))->toBeTrue() diff --git a/tests/Unit/Models/VerificationCodeGuardTest.php b/tests/Unit/Models/VerificationCodeGuardTest.php new file mode 100644 index 00000000..28bac120 --- /dev/null +++ b/tests/Unit/Models/VerificationCodeGuardTest.php @@ -0,0 +1,184 @@ +messages[] = compact('to', 'message', 'mediaUrls', 'params'); + + return (object) ['sid' => 'SM-guarded']; + } +} + +function verification_code_guard_database(array $guard = []): VerificationCodeGuardTwilioFake +{ + EloquentModel::clearBootedModels(); + session()->flush(); + + $connection = [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]; + + $container = bind_test_container([ + 'app.name' => 'Fleetbase', + 'sms.default_provider' => 'twilio', + 'sms.guard' => array_replace_recursive([ + 'enabled' => true, + 'mode' => 'enforce', + 'limits' => [ + 'destination' => ['cooldown_seconds' => 60, 'per_hour' => 3, 'per_day' => 5], + 'company' => ['per_minute' => 100, 'per_hour' => 100, 'per_day' => 100], + 'probation' => ['days' => 0], + ], + 'breaker' => ['alert_percentages' => [], 'distinct_destinations_per_10m' => 100], + ], $guard), + 'database.default' => 'testing', + 'database.connections.testing' => $connection, + 'database.connections.mysql' => $connection, + 'fleetbase.connection.db' => 'testing', + ]); + $container->instance('cache', new CacheRepository(new ArrayStore())); + $container->instance('request', Request::create('/storefront/v1/customers/request-creation-code', 'POST', [], [], [], ['REMOTE_ADDR' => '203.0.113.9', 'HTTP_USER_AGENT' => 'StorefrontApp/3.1'])); + $container->instance('responsecache', new class { + public function clear(): void + { + } + }); + $twilio = new VerificationCodeGuardTwilioFake(); + $container->instance('twilio', $twilio); + Facade::clearResolvedInstances(); + + $capsule = new Capsule($container); + $capsule->addConnection($connection, 'testing'); + $capsule->addConnection($connection, 'mysql'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + $capsule->getDatabaseManager()->setDefaultConnection('testing'); + $container->instance('db', $capsule->getDatabaseManager()); + Facade::clearResolvedInstance('db'); + + $schema = $capsule->getConnection('testing')->getSchemaBuilder(); + $schema->create('verification_codes', function ($table) { + $table->string('uuid')->primary(); + $table->string('subject_uuid')->nullable()->index(); + $table->string('subject_type')->nullable(); + $table->string('code')->nullable(); + $table->string('for')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->text('meta')->nullable(); + $table->string('status')->nullable(); + $table->timestamp('deleted_at')->nullable(); + $table->timestamps(); + }); + // Company lives on the core connection, like the verification code model test. + $capsule->getConnection('mysql')->getSchemaBuilder()->create('companies', function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable()->index(); + $table->string('slug')->nullable(); + $table->text('options')->nullable(); + $table->timestamp('deleted_at')->nullable(); + $table->timestamps(); + }); + + session(['company' => 'company-1', 'storefront_store' => 'store-1']); + + return $twilio; +} + +function verification_code_guard_subject(string $phone): User +{ + $user = new User(); + $user->setRawAttributes(['uuid' => 'user-guarded', 'name' => 'Ada Lovelace', 'phone' => $phone], true); + + return $user; +} + +afterEach(function () { + Carbon::setTestNow(); + session()->flush(); + EloquentModel::unsetConnectionResolver(); + EloquentModel::clearBootedModels(); + Facade::clearResolvedInstances(); +}); + +it('writes no verification code row when the guard refuses the send', function () { + $twilio = verification_code_guard_database(); + + $refusal = null; + try { + VerificationCode::generateSmsVerificationFor(verification_code_guard_subject('+4471'), 'storefront_create_customer', ['meta' => ['identity' => '+4471']]); + } catch (SmsBlockedException $e) { + $refusal = $e; + } + + expect($refusal)->toBeInstanceOf(SmsBlockedException::class) + ->and($refusal->status())->toBe(422) + ->and(VerificationCode::query()->count())->toBe(0) + ->and($twilio->messages)->toBe([]); +}); + +it('traces an allowed send on the code row, sends once and refuses the repeat inside the cooldown', function () { + $twilio = verification_code_guard_database(); + + $code = VerificationCode::generateSmsVerificationFor(verification_code_guard_subject('+447400100123'), 'storefront_create_customer', [ + 'meta' => ['identity' => '+447400100123'], + 'messageCallback' => fn (VerificationCode $verificationCode) => 'Code: ' . $verificationCode->code, + ]); + + $repeat = null; + try { + VerificationCode::generateSmsVerificationFor(verification_code_guard_subject('+447400100123'), 'storefront_create_customer', ['meta' => ['identity' => '+447400100123']]); + } catch (SmsBlockedException $e) { + $repeat = $e; + } + + $entries = collect(app('log')->entries)->pluck(1); + + expect($code->exists)->toBeTrue() + ->and($code->meta['identity'])->toBe('+447400100123') + ->and($code->meta['trace'])->toMatchArray([ + 'company_uuid' => 'company-1', + 'tenant_type' => 'store', + 'tenant_uuid' => 'store-1', + 'source' => 'storefront', + 'ip' => '203.0.113.9', + 'user_agent' => 'StorefrontApp/3.1', + 'destination_country' => 'GB', + 'decision' => 'allowed', + ]) + ->and($twilio->messages)->toHaveCount(1) + ->and($twilio->messages[0]['to'])->toBe('+447400100123') + ->and($twilio->messages[0]['message'])->toBe('Code: ' . $code->code) + ->and($repeat)->toBeInstanceOf(SmsBlockedException::class) + ->and($repeat->status())->toBe(429) + ->and(VerificationCode::query()->count())->toBe(1) + ->and($entries->filter(fn ($name) => $name === 'sms.sent')->count())->toBe(1) + ->and($entries->filter(fn ($name) => $name === 'sms.blocked')->count())->toBe(1); +}); + +it('leaves the code row untouched when the guard is disabled', function () { + $twilio = verification_code_guard_database(['enabled' => false]); + + $code = VerificationCode::generateSmsVerificationFor(verification_code_guard_subject('+447400100123'), 'phone_login', ['meta' => ['channel' => 'sms']]); + + expect($code->meta)->toBe(['channel' => 'sms']) + ->and($twilio->messages)->toHaveCount(1) + ->and(collect(app('log')->entries)->pluck(1)->all())->not->toContain('sms.sent'); +}); diff --git a/tests/Unit/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/Models/VerificationCodeTracingTest.php b/tests/Unit/Models/VerificationCodeTracingTest.php new file mode 100644 index 00000000..45c32bd7 --- /dev/null +++ b/tests/Unit/Models/VerificationCodeTracingTest.php @@ -0,0 +1,218 @@ +messages[] = compact('to', 'message'); + + return (object) ['sid' => 'SM-traced']; + } +} + +class VerificationCodeTracingMailerFake +{ + public array $sent = []; + + public function to(mixed $recipient): self + { + return $this; + } + + public function send(mixed $mail): void + { + $this->sent[] = $mail; + } +} + +/** + * A verification_codes table WITH the tracing columns, the ledger table, and a guard on. + */ +function verification_code_tracing_database(bool $guard = true): VerificationCodeTracingTwilioFake +{ + EloquentModel::clearBootedModels(); + session()->flush(); + + $connection = [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]; + + $container = bind_test_container([ + 'app.name' => 'Fleetbase', + 'app.key' => 'base64:test-app-key', + 'sms.default_provider' => 'twilio', + 'sms.guard' => [ + 'enabled' => $guard, + 'limits' => ['destination' => ['cooldown_seconds' => 0, 'per_hour' => null, 'per_day' => null], 'probation' => ['days' => 0]], + 'breaker' => ['alert_percentages' => [], 'distinct_destinations_per_10m' => 500, 'new_country_min_sends' => null, 'conversion_min_issued' => null], + ], + 'database.default' => 'mysql', + 'database.connections.mysql' => $connection, + 'fleetbase.connection.db' => 'mysql', + ]); + $container->instance('cache', new CacheRepository(new ArrayStore())); + $container->instance('request', Request::create('/storefront/v1/customers/request-creation-code', 'POST', [], [], [], ['REMOTE_ADDR' => '203.0.113.9', 'HTTP_USER_AGENT' => 'StorefrontApp/3.1'])); + $container->instance('responsecache', new class { + public function clear(): void + { + } + }); + $twilio = new VerificationCodeTracingTwilioFake(); + $container->instance('twilio', $twilio); + $container->instance('mail.manager', new VerificationCodeTracingMailerFake()); + Facade::clearResolvedInstances(); + + $capsule = new Capsule($container); + $capsule->addConnection($connection, 'mysql'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + $capsule->getDatabaseManager()->setDefaultConnection('mysql'); + $container->instance('db', $capsule->getDatabaseManager()); + Facade::clearResolvedInstance('db'); + + SmsGuardFixture::verificationCodesSchema($capsule); + SmsGuardFixture::companiesSchema($capsule); + SmsGuardFixture::smsSendsSchema($capsule); + + session(['company' => 'company-1', 'storefront_store' => 'store-1']); + + return $twilio; +} + +function verification_code_tracing_subject(array $attributes = []): User +{ + $user = new User(); + $user->setRawAttributes(array_merge(['uuid' => 'user-traced', 'name' => 'Ada Lovelace', 'phone' => '+447400100123', 'email' => 'ada@example.test'], $attributes), true); + + return $user; +} + +afterEach(function () { + Carbon::setTestNow(); + session()->flush(); + EloquentModel::unsetConnectionResolver(); + EloquentModel::clearBootedModels(); + Facade::clearResolvedInstances(); +}); + +it('fills the tracing columns and links the ledger row for an sms code', function () { + $twilio = verification_code_tracing_database(); + + $code = VerificationCode::generateSmsVerificationFor(verification_code_tracing_subject(), 'storefront_create_customer', [ + 'meta' => ['identity' => '+447400100123'], + ]); + + $send = SmsSend::first(); + + expect(VerificationCode::supportsTracingColumns())->toBeTrue() + ->and($code->company_uuid)->toBe('company-1') + ->and($code->identity)->toBe('+447400100123') + ->and($code->channel)->toBe('sms') + ->and($code->ip_address)->toBe('203.0.113.9') + ->and($code->user_agent)->toBe('StorefrontApp/3.1') + ->and($code->sms_send_uuid)->toBe($send->uuid) + ->and($code->consumed_at)->toBeNull() + ->and($send->verification_code_uuid)->toBe($code->uuid) + ->and($send->status)->toBe('sent') + ->and($twilio->messages)->toHaveCount(1); +}); + +it('issues a hashed single-use code when asked, and the message still carries the plain code', function () { + $twilio = verification_code_tracing_database(); + Carbon::setTestNow(Carbon::parse('2026-10-13 10:00:00', 'UTC')); + + $code = VerificationCode::generateSmsVerificationFor(verification_code_tracing_subject(), 'storefront_create_customer', [ + 'hashed' => true, + 'meta' => ['identity' => '+447400100123'], + 'messageCallback' => fn (VerificationCode $verification) => 'Your code is ' . $verification->code, + ]); + + $stored = VerificationCode::withoutGlobalScopes()->where('uuid', $code->uuid)->first(); + $found = VerificationCode::findActiveFor('storefront_create_customer', '+447400100123'); + + expect($code->plainCode)->toMatch('/^\d{6}$/') + ->and($stored->code)->toBe(VerificationCode::hashCode($code->plainCode)) + ->and($stored->code)->not->toBe($code->plainCode) + ->and($stored->meta)->toMatchArray(['identity' => '+447400100123', 'hashed' => true, 'attempts' => 0]) + ->and($stored->status)->toBe('active') + ->and($stored->expires_at->toDateTimeString())->toBe('2026-10-13 10:10:00') + ->and($twilio->messages[0]['message'])->toBe('Your code is ' . $code->plainCode) + ->and($found?->uuid)->toBe($code->uuid) + ->and($found->check('000000'))->toBe(VerificationCode::CHECK_INVALID) + ->and($found->check($code->plainCode))->toBe(VerificationCode::CHECK_VALID); + + $found->consume(); + + expect(VerificationCode::findActiveFor('storefront_create_customer', '+447400100123'))->toBeNull() + ->and($found->fresh()->status)->toBe('used') + ->and($found->fresh()->consumed_at)->not->toBeNull() + ->and($found->fresh()->getMeta('consumed_at'))->not->toBeNull(); +}); + +it('finds live codes by identity or subject, skipping used, locked and expired ones, including legacy rows', function () { + verification_code_tracing_database(false); + $db = EloquentModel::getConnectionResolver()->connection('mysql'); + + $row = fn (string $uuid, string $code, ?string $identity, ?string $subject, ?string $status, Carbon $expires, Carbon $created, ?array $meta = null) => [ + 'uuid' => $uuid, + 'code' => $code, + 'for' => 'storefront_login', + 'identity' => $identity, + 'subject_uuid' => $subject, + 'status' => $status, + 'meta' => $meta ? json_encode($meta) : null, + 'expires_at' => $expires, + 'created_at' => $created, + 'updated_at' => Carbon::now(), + ]; + $db->table('verification_codes')->insert([ + $row('legacy', '111111', null, 'user-1', null, Carbon::now()->addHour(), Carbon::now()->subMinutes(3), ['identity' => '+447400100001']), + $row('used', '222222', '+447400100001', null, 'used', Carbon::now()->addHour(), Carbon::now()->subMinutes(2)), + $row('locked', '333333', '+447400100001', null, 'locked', Carbon::now()->addHour(), Carbon::now()->subMinute()), + $row('expired', '444444', '+447400100001', null, 'active', Carbon::now()->subMinute(), Carbon::now()), + $row('other', '555555', '+447400100002', 'user-2', 'active', Carbon::now()->addHour(), Carbon::now()), + ]); + + expect(VerificationCode::findActiveFor('storefront_login', '+447400100001')?->uuid)->toBe('legacy') + ->and(VerificationCode::findActiveFor('storefront_login', '+447400100001', 'user-1')?->uuid)->toBe('legacy') + ->and(VerificationCode::findActiveFor('storefront_login', '+447400100001', 'user-9'))->toBeNull() + ->and(VerificationCode::findActiveFor('storefront_login', '+447400100002')?->uuid)->toBe('other') + ->and(VerificationCode::findActiveForSubject('storefront_login', 'user-2')?->uuid)->toBe('other') + ->and(VerificationCode::findActiveForSubject('storefront_login', 'user-9'))->toBeNull() + ->and(VerificationCode::findActiveFor('storefront_login', '+447400100001')->check('111111'))->toBe(VerificationCode::CHECK_VALID); +}); + +it('traces email codes too and leaves tracing alone without the guard', function () { + verification_code_tracing_database(false); + + $email = VerificationCode::generateEmailVerificationFor(verification_code_tracing_subject(), 'storefront_create_customer', ['meta' => ['identity' => 'ada@example.test']]); + $sms = VerificationCode::generateSmsVerificationFor(verification_code_tracing_subject(), 'storefront_login'); + + expect($email->channel)->toBe('email') + ->and($email->identity)->toBe('ada@example.test') + ->and($email->company_uuid)->toBe('company-1') + ->and($email->ip_address)->toBe('203.0.113.9') + ->and($sms->channel)->toBe('sms') + ->and($sms->identity)->toBe('+447400100123') + ->and($sms->sms_send_uuid)->toBeNull() + ->and($sms->meta)->toBe([]) + ->and(SmsSend::count())->toBe(0); +}); diff --git a/tests/Unit/MultiProviderSmsServiceTest.php b/tests/Unit/MultiProviderSmsServiceTest.php index 1283d32c..29929317 100644 --- a/tests/Unit/MultiProviderSmsServiceTest.php +++ b/tests/Unit/MultiProviderSmsServiceTest.php @@ -156,6 +156,7 @@ function config($key = null, $default = null) '+44' => SmsService::PROVIDER_MESSAGEBIRD, ], 'providers' => [], + 'guard' => ['enabled' => false], ], ])); $app->instance('log', new NullLogger()); diff --git a/tests/Unit/Notifications/SmsServiceChannelTest.php b/tests/Unit/Notifications/SmsServiceChannelTest.php new file mode 100644 index 00000000..86918ae4 --- /dev/null +++ b/tests/Unit/Notifications/SmsServiceChannelTest.php @@ -0,0 +1,81 @@ +sent[] = compact('to', 'text', 'options'); + + return ['success' => true]; + } +} + +class SmsServiceChannelTestNotification extends Notification +{ + public string $smsPurpose = 'storefront_order_alert'; + + public function __construct(public mixed $message) + { + } + + public function toSms($notifiable): mixed + { + return $this->message; + } +} + +function sms_service_channel_service(): SmsServiceChannelTestService +{ + bind_test_container(['sms.default_provider' => 'twilio']); + + return new SmsServiceChannelTestService(); +} + +test('sms service channel sends a notification text through SmsService with its purpose', function () { + $service = sms_service_channel_service(); + $channel = new SmsServiceChannel($service); + + $notifiable = new class { + public ?string $company_uuid = 'company-1'; + + public function routeNotificationFor(string $driver, $notification = null): ?string + { + return $driver === 'twilio' ? '+447400100123' : null; + } + }; + + $channel->send($notifiable, new SmsServiceChannelTestNotification('New order!')); + $channel->send($notifiable, new SmsServiceChannelTestNotification((object) ['content' => 'From an object'])); + + expect($service->sent)->toHaveCount(2) + ->and($service->sent[0])->toBe(['to' => '+447400100123', 'text' => 'New order!', 'options' => ['purpose' => 'storefront_order_alert', 'company_uuid' => 'company-1']]) + ->and($service->sent[1]['text'])->toBe('From an object'); +}); + +test('sms service channel falls back to a phone attribute and sends nothing without a number or text', function () { + $service = sms_service_channel_service(); + $channel = new SmsServiceChannel($service); + + $withPhone = new class { + public string $phone = '+447400100124'; + }; + $silent = new class { + public ?string $phone = null; + }; + + $channel->send($withPhone, new SmsServiceChannelTestNotification('Hello')); + $channel->send($silent, new SmsServiceChannelTestNotification('Hello')); + $channel->send($withPhone, new SmsServiceChannelTestNotification('')); + $channel->send($withPhone, new class extends Notification { + }); + + expect($service->sent)->toHaveCount(1) + ->and($service->sent[0]['to'])->toBe('+447400100124') + ->and($service->sent[0]['options']['company_uuid'])->toBeNull(); +}); diff --git a/tests/Unit/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/SecurityBehavioralTest.php b/tests/Unit/SecurityBehavioralTest.php index d8be348f..75d870cd 100644 --- a/tests/Unit/SecurityBehavioralTest.php +++ b/tests/Unit/SecurityBehavioralTest.php @@ -308,6 +308,27 @@ function security_behavioral_bind_auth_guard(bool $validateResult = true): Secur return $guard; } +/** + * The trait deleteRecord also layers IAM directives onto the query; those need the + * users/permissions graph, so this stand-in leaves directives out and keeps the + * tenant clause (and its real schema column check) as the only scoping. + */ +class SecurityBehavioralApiCredential extends ApiCredential +{ + public function applyDirectivesToQuery(Request $request, $builder) + { + return $builder; + } +} + +function security_behavioral_credential_controller(): ApiCredentialController +{ + $controller = new ApiCredentialController(); + $controller->model = new SecurityBehavioralApiCredential(); + + return $controller; +} + afterEach(function () { session()->flush(); Carbon::setTestNow(); @@ -367,6 +388,30 @@ function security_behavioral_bind_auth_guard(bool $validateResult = true): Secur ->and(Notification::where('id', 'notif-theirs')->exists())->toBeTrue(); }); +test('default deleteRecord refuses to delete another company record', function () { + security_behavioral_database(); + + // ApiCredentialController inherits the trait deleteRecord; acting as company-1, + // company-2's credential must not resolve. + $request = security_behavioral_request('DELETE', '/v1/api-credentials/key_theirs'); + $response = security_behavioral_credential_controller()->deleteRecord('key_theirs', $request); + + expect($response->getStatusCode())->toBe(404) + ->and(ApiCredential::where('uuid', 'cred-theirs')->whereNull('deleted_at')->exists())->toBeTrue() + ->and(ApiCredential::where('uuid', 'cred-mine')->whereNull('deleted_at')->exists())->toBeTrue(); +}); + +test('default deleteRecord deletes a record owned by the active company', function () { + security_behavioral_database(); + + $request = security_behavioral_request('DELETE', '/v1/api-credentials/key_mine'); + $response = security_behavioral_credential_controller()->deleteRecord('key_mine', $request); + + expect($response->getStatusCode())->toBe(200) + ->and(ApiCredential::where('uuid', 'cred-mine')->whereNull('deleted_at')->exists())->toBeFalse() + ->and(ApiCredential::where('uuid', 'cred-theirs')->whereNull('deleted_at')->exists())->toBeTrue(); +}); + test('file download refuses to resolve another company file (IDOR)', function () { security_behavioral_database(); diff --git a/tests/Unit/SmsServiceGuardTest.php b/tests/Unit/SmsServiceGuardTest.php new file mode 100644 index 00000000..5ad59e67 --- /dev/null +++ b/tests/Unit/SmsServiceGuardTest.php @@ -0,0 +1,144 @@ +dispatches[] = compact('to', 'text', 'options'); + + if ($this->failure) { + throw $this->failure; + } + + return ['success' => true, 'message_id' => 'SM-probe']; + } +} + +function sms_service_guard_setup(array $guard = []): SmsGuard +{ + session()->flush(); + + $container = bind_test_container([ + 'sms.default_provider' => 'twilio', + 'sms.guard' => array_replace_recursive([ + 'enabled' => true, + 'mode' => 'enforce', + 'limits' => [ + 'destination' => ['cooldown_seconds' => 60, 'per_hour' => 3, 'per_day' => 5], + 'probation' => ['days' => 0], + ], + 'breaker' => ['alert_percentages' => [], 'distinct_destinations_per_10m' => 100], + ], $guard), + ]); + $cache = new CacheRepository(new ArrayStore()); + $container->instance('cache', $cache); + $container->instance('request', Request::create('/v1/drivers/login-with-sms', 'POST', [], [], [], ['REMOTE_ADDR' => '198.51.100.7'])); + Facade::clearResolvedInstances(); + session(['company' => 'company-api']); + + $guard = new SmsGuard($cache); + $container->instance(SmsGuard::class, $guard); + + return $guard; +} + +afterEach(function () { + session()->flush(); + app()->forgetInstance(SmsGuard::class); + Facade::clearResolvedInstances(); +}); + +test('sms service refuses a blocked destination before any provider is called', function () { + sms_service_guard_setup(); + $service = new SmsServiceGuardProbe(); + + $refusal = null; + try { + $service->send('+449098790123', 'hello', ['purpose' => 'driver_login']); + } catch (SmsBlockedException $e) { + $refusal = $e; + } + + expect($refusal)->toBeInstanceOf(SmsBlockedException::class) + ->and($refusal->status())->toBe(422) + ->and($service->dispatches)->toBe([]); +}); + +test('sms service reserves, normalises the destination, strips guard options and settles', function () { + $guard = sms_service_guard_setup(); + $service = new SmsServiceGuardProbe(); + + $result = $service->send('44 7400 100123', 'hello', ['purpose' => 'driver_login', 'company_uuid' => 'company-api', 'twilioParams' => ['from' => 'ACME']]); + $entries = collect(app('log')->entries); + + $cooldown = null; + try { + $service->send('+447400100123', 'hello again', ['purpose' => 'driver_login']); + } catch (SmsBlockedException $e) { + $cooldown = $e; + } + + expect($result['provider'])->toBe('twilio') + ->and($service->dispatches)->toHaveCount(1) + ->and($service->dispatches[0]['to'])->toBe('+447400100123') + ->and($service->dispatches[0]['options'])->toBe(['twilioParams' => ['from' => 'ACME']]) + ->and($entries->where(1, 'sms.sent'))->toHaveCount(1) + ->and($entries->where(1, 'sms.sent')->first()[2])->toMatchArray(['purpose' => 'driver_login', 'company_uuid' => 'company-api', 'source' => 'api', 'ip' => '198.51.100.7', 'message_id' => 'SM-probe']) + ->and($cooldown)->toBeInstanceOf(SmsBlockedException::class) + ->and($cooldown->status())->toBe(429) + ->and($guard->limiter()->attempts(SmsGuard::CACHE_PREFIX . 'company:company-api:per_day'))->toBe(1); +}); + +test('sms service counts a send once when the caller hands over its reservation, and settles failures', function () { + $guard = sms_service_guard_setup(['limits' => ['destination' => ['cooldown_seconds' => 0]]]); + $service = new SmsServiceGuardProbe(); + + $reservation = $guard->reserve(SmsContext::resolve('+447400100123', ['purpose' => 'driver_login'])); + $service->send('+447400100123', 'hello', ['sms_reservation' => $reservation, 'purpose' => 'driver_login']); + + $service->failure = new RuntimeException('carrier refused'); + $thrown = null; + try { + $service->send('+447400100124', 'hello', ['purpose' => 'driver_login']); + } catch (RuntimeException $e) { + $thrown = $e; + } + + $entries = collect(app('log')->entries); + + expect($reservation->settled)->toBeTrue() + ->and($guard->limiter()->attempts(SmsGuard::CACHE_PREFIX . 'company:company-api:per_day'))->toBe(2) + ->and($thrown?->getMessage())->toBe('carrier refused') + ->and($entries->where(1, 'sms.failed'))->toHaveCount(1) + ->and($entries->where(1, 'sms.failed')->first()[2]['error'])->toBe('carrier refused'); +}); + +test('sms service treats a provider result with success false as a failed send', function () { + sms_service_guard_setup(['limits' => ['destination' => ['cooldown_seconds' => 0]]]); + $service = new class extends SmsService { + protected function sendViaTwilio(string $to, string $text, array $options = []): array + { + return ['success' => false, 'error' => 'invalid sender']; + } + }; + + $service->send('+447400100123', 'hello', ['purpose' => 'driver_login']); + + $failed = collect(app('log')->entries)->where(1, 'sms.failed')->first(); + + expect($failed)->not->toBeNull() + ->and($failed[2]['error'])->toBe('invalid sender'); +}); diff --git a/tests/Unit/Support/ApiModelCacheTest.php b/tests/Unit/Support/ApiModelCacheTest.php index 9bfe9689..d302cab1 100644 --- a/tests/Unit/Support/ApiModelCacheTest.php +++ b/tests/Unit/Support/ApiModelCacheTest.php @@ -3,8 +3,14 @@ use Fleetbase\Support\ApiModelCache; use Fleetbase\Traits\HasApiModelCache; use Illuminate\Database\Capsule\Manager as Capsule; +use Illuminate\Database\DatabaseTransactionsManager; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasManyThrough; +use Illuminate\Database\Eloquent\Relations\MorphTo; +use Illuminate\Database\Eloquent\Relations\Pivot; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Events\Dispatcher; use Illuminate\Http\Request; @@ -80,6 +86,144 @@ class ApiModelCacheSoftDeletingTraitTestModel extends ApiModelCacheTraitTestMode use SoftDeletes; } +class ApiModelCacheDependencyPayload extends Model +{ + use HasApiModelCache; + + protected $table = 'payloads'; + protected $primaryKey = 'uuid'; + public $incrementing = false; + protected $keyType = 'string'; + protected $guarded = []; +} + +class ApiModelCacheDependencyTag extends Model +{ + protected $table = 'tags'; + protected $primaryKey = 'uuid'; + public $incrementing = false; + protected $keyType = 'string'; + protected $guarded = []; +} + +class ApiModelCacheDependencyOrderTag extends Pivot +{ + use HasApiModelCache; + + protected $table = 'order_tags'; + public $timestamps = false; +} + +class ApiModelCacheRelationsOrder extends ApiModelCacheTraitTestModel +{ + public static int $sideEffects = 0; + + public function payload(): BelongsTo + { + return $this->belongsTo(ApiModelCacheDependencyPayload::class, 'payload_uuid', 'uuid'); + } + + public function tags(): BelongsToMany + { + return $this->belongsToMany(ApiModelCacheDependencyTag::class, 'order_tags', 'order_uuid', 'tag_uuid') + ->using(ApiModelCacheDependencyOrderTag::class); + } + + public function subject(): MorphTo + { + return $this->morphTo(); + } + + public function tagPayloads(): HasManyThrough + { + return $this->hasManyThrough(ApiModelCacheDependencyPayload::class, ApiModelCacheDependencyTag::class, 'order_uuid', 'tag_uuid', 'uuid', 'uuid'); + } + + public function reconnectingPayload(): BelongsTo + { + $this->setConnection('mysql'); + + return $this->belongsTo(ApiModelCacheDependencyPayload::class, 'payload_uuid', 'uuid'); + } + + public function summary(): string + { + return 'not a relation'; + } + + // An untyped relation: declarations may name it, client paths may not + public function untypedPayload() + { + return $this->belongsTo(ApiModelCacheDependencyPayload::class, 'payload_uuid', 'uuid'); + } + + // Shaped like Order::dispatch(): public, no required arguments, side effects + public function dispatch(bool $save = true) + { + static::$sideEffects++; + + return $this; + } + + public function exploding(): BelongsTo + { + throw new RuntimeException('needs a loaded model'); + } +} + +class ApiModelCacheDependencyOrder extends ApiModelCacheRelationsOrder +{ + protected $apiCacheDependencies = ['payload', 'tags', 'audit_logs']; +} + +class ApiModelCacheUntypedDependencyOrder extends ApiModelCacheRelationsOrder +{ + protected $apiCacheDependencies = ['untypedPayload']; +} + +class ApiModelCacheExplodingDependencyOrder extends ApiModelCacheRelationsOrder +{ + protected $apiCacheDependencies = ['tags', 'exploding']; +} + +class ApiModelCacheMorphDependencyOrder extends ApiModelCacheRelationsOrder +{ + protected $apiCacheDependencies = ['subject']; +} + +class ApiModelCacheUnresolvableDependencyOrder extends ApiModelCacheRelationsOrder +{ + protected $apiCacheDependencies = ['payload.customer']; +} + +class ApiModelCacheNotRelationDependencyOrder extends ApiModelCacheRelationsOrder +{ + protected $apiCacheDependencies = ['summary']; +} + +class ApiModelCachePartlyResolvableDependencyOrder extends ApiModelCacheRelationsOrder +{ + protected $apiCacheDependencies = ['tags', 'payload.customer']; +} + +class ApiModelCacheUncallableRelationsOrder extends ApiModelCacheRelationsOrder +{ + public static function staticPayload(): BelongsTo + { + return (new static())->belongsTo(ApiModelCacheDependencyPayload::class, 'payload_uuid', 'uuid'); + } + + public function payloadFor(string $key): BelongsTo + { + return $this->belongsTo(ApiModelCacheDependencyPayload::class, $key, 'uuid'); + } + + protected function hiddenPayload(): BelongsTo + { + return $this->belongsTo(ApiModelCacheDependencyPayload::class, 'payload_uuid', 'uuid'); + } +} + class ApiModelCacheTestLock { public function __construct(private ApiModelCacheTestStore $store) @@ -140,18 +284,36 @@ public function flush(): bool class ApiModelCacheTestStore { - public array $values = []; - public array $taggedValues = []; - public array $flushedTags = []; - public array $forgotten = []; - public bool $throwOnTags = false; - public bool $lockReturnsNull = false; + public array $values = []; + public array $taggedValues = []; + public array $flushedTags = []; + public array $forgotten = []; + public bool $throwOnTags = false; + public bool $throwOnMany = false; + public bool $throwOnIncrement = false; + public bool $lockReturnsNull = false; + public bool $flushRemovesValues = true; + public bool $incrementCreatesMissing = true; public function get(string $key, mixed $default = null): mixed { return $this->values[$key] ?? $default; } + public function many(array $keys): array + { + if ($this->throwOnMany) { + throw new RuntimeException('cache unavailable'); + } + + $values = []; + foreach ($keys as $key) { + $values[$key] = $this->values[$key] ?? null; + } + + return $values; + } + public function put(string $key, mixed $value): bool { $this->values[$key] = $value; @@ -159,8 +321,27 @@ public function put(string $key, mixed $value): bool return true; } - public function increment(string $key): int + public function add(string $key, mixed $value): bool + { + if (array_key_exists($key, $this->values)) { + return false; + } + + $this->values[$key] = $value; + + return true; + } + + public function increment(string $key): int|false { + if ($this->throwOnIncrement) { + throw new RuntimeException('cache unavailable'); + } + + if (!$this->incrementCreatesMissing && !array_key_exists($key, $this->values)) { + return false; + } + $this->values[$key] = ($this->values[$key] ?? 0) + 1; return $this->values[$key]; @@ -202,7 +383,10 @@ public function forgetTagged(array $tags, string $key): void public function flushTagged(array $tags): void { - unset($this->taggedValues[$this->tagKey($tags)]); + // Laravel 10 RedisTaggedCache on phpredis >= 6.1 drops the tag sets but not the values + if ($this->flushRemovesValues) { + unset($this->taggedValues[$this->tagKey($tags)]); + } } private function tagKey(array $tags): string @@ -246,6 +430,7 @@ function api_model_cache_fixture(array $config = []): ApiModelCacheTestStore 'api.cache.ttl.query' => 111, 'api.cache.ttl.model' => 222, 'api.cache.ttl.relationship' => 333, + 'api.cache.scope.session' => null, 'cache.default' => 'array', ], $config)); @@ -280,6 +465,16 @@ function api_model_cache_request(array $query = [], mixed $company = 'company-1' return $request; } +function api_model_cache_scoped_request(string $path, array $session, array $query = []): Request +{ + $request = Request::create($path, 'GET', $query); + $store = new Store('testing', new ApiModelCacheArraySessionHandler()); + $store->put($session); + $request->setLaravelSession($store); + + return $request; +} + function api_model_cache_internal_request(array $query = [], mixed $company = 'company-1'): Request { $request = api_model_cache_request($query, $company); @@ -295,6 +490,31 @@ public function uri(): string return $request; } +function api_model_cache_dependency_database(): Capsule +{ + $capsule = api_model_cache_trait_database(); + $schema = $capsule->getConnection('mysql')->getSchemaBuilder(); + + $schema->table('orders', fn ($table) => $table->string('payload_uuid')->nullable()); + $schema->create('payloads', function ($table) { + $table->string('uuid')->primary(); + $table->string('company_uuid')->nullable(); + $table->string('name')->nullable(); + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + }); + $schema->create('tags', function ($table) { + $table->string('uuid')->primary(); + $table->string('name')->nullable(); + }); + $schema->create('order_tags', function ($table) { + $table->string('order_uuid'); + $table->string('tag_uuid'); + }); + + return $capsule; +} + function api_model_cache_trait_database(): Capsule { Model::clearBootedModels(); @@ -404,11 +624,11 @@ public function gc(int $max_lifetime): int|false ]), ['callback' => true]); expect($first)->toBe($second) - ->and($first)->toStartWith('{api_query}:orders:company_company-1:v7:') + ->and($first)->toStartWith('{api_query}:orders:company_company-1:v0.7:') ->and(ApiModelCache::generateModelCacheKey($model, 'order-1', ['customer', 'payload'])) - ->toBe('{api_model}:orders:order-1:' . md5(json_encode(['customer', 'payload']))) + ->toBe('{api_model}:orders:v0:order-1:' . md5(json_encode(['customer', 'payload']))) ->and(ApiModelCache::generateRelationshipCacheKey($model, 'payload')) - ->toBe('{api_relation}:orders:order-1:payload') + ->toBe('{api_relation}:orders:v0:order-1:payload') ->and(ApiModelCache::generateCacheTags($model, 'company-1', true)) ->toBe(['api_cache', 'api_model:orders', 'api_query:orders', 'company:company-1']); }); @@ -428,7 +648,7 @@ public function gc(int $max_lifetime): int|false expect($first->all())->toBe(['fresh-result']) ->and($calls)->toBe(1) ->and(ApiModelCache::getCacheStatus())->toBe('MISS') - ->and(ApiModelCache::getCacheKey())->toStartWith('{api_query}:orders:company_company-1:v1:'); + ->and(ApiModelCache::getCacheKey())->toStartWith('{api_query}:orders:company_company-1:v0.0:'); $second = ApiModelCache::cacheQueryResult($model, $request, function () use (&$calls) { $calls++; @@ -500,7 +720,7 @@ public function gc(int $max_lifetime): int|false ->and($secondModel)->toBe(['uuid' => 'order-1']) ->and($modelCalls)->toBe(1) ->and(ApiModelCache::getCacheStatus())->toBe('HIT') - ->and(ApiModelCache::getCacheKey())->toStartWith('{api_model}:orders:order-1:'); + ->and(ApiModelCache::getCacheKey())->toStartWith('{api_model}:orders:v0:order-1:'); ApiModelCache::resetCacheStatus(); $relationshipCalls = 0; @@ -519,7 +739,7 @@ public function gc(int $max_lifetime): int|false ->and($secondRelation)->toBe(['uuid' => 'payload-1']) ->and($relationshipCalls)->toBe(1) ->and(ApiModelCache::getCacheStatus())->toBe('HIT') - ->and(ApiModelCache::getCacheKey())->toBe('{api_relation}:orders:order-1:payload'); + ->and(ApiModelCache::getCacheKey())->toBe('{api_relation}:orders:v0:order-1:payload'); }); test('api model cache bypasses model relationship and invalidation work when disabled', function () { @@ -568,19 +788,470 @@ public function gc(int $max_lifetime): int|false expect(ApiModelCache::getCacheStatus())->toBeNull() ->and(ApiModelCache::getCacheKey())->toBeNull() ->and($store->get('api_query_version:orders:company-1'))->toBe(1) + ->and($store->get('api_model_version:orders'))->toBe(1) ->and($store->flushedTags)->toContain(['api_cache', 'api_model:orders', 'company:company-1']) ->and($store->flushedTags)->toContain(['api_cache', 'api_model:orders', 'api_query:orders', 'company:company-1']); ApiModelCache::invalidateQueryCache($model, $request); expect($store->forgotten[0]['tags'])->toBe(['api_cache', 'api_model:orders', 'api_query:orders', 'company:company-1']) - ->and($store->forgotten[0]['key'])->toStartWith('{api_query}:orders:company_company-1:v1:'); + ->and($store->forgotten[0]['key'])->toStartWith('{api_query}:orders:company_company-1:v0.1:'); ApiModelCache::invalidateCompanyCache('company-1'); expect($store->flushedTags)->toContain(['company:company-1']); }); +test('api model cache does not serve a list cached before the first invalidation when the tag flush leaves it behind', function () { + $store = api_model_cache_fixture(); + $store->flushRemovesValues = false; + $model = api_model_cache_model(); + $request = api_model_cache_request(['status' => 'active']); + + expect(ApiModelCache::cacheQueryResult($model, $request, fn () => collect(['stale'])))->toEqual(collect(['stale'])); + $keyBeforeInvalidation = ApiModelCache::getCacheKey(); + + ApiModelCache::invalidateModelCache($model, 'company-1'); + + expect(ApiModelCache::cacheQueryResult($model, $request, fn () => collect(['fresh'])))->toEqual(collect(['fresh'])) + ->and(ApiModelCache::getCacheStatus())->toBe('MISS') + ->and(ApiModelCache::getCacheKey())->not->toBe($keyBeforeInvalidation) + ->and($store->get('api_query_version:orders:company-1'))->toBe(1); + + ApiModelCache::invalidateModelCache($model, 'company-1'); + + expect(ApiModelCache::cacheQueryResult($model, $request, fn () => collect(['fresher'])))->toEqual(collect(['fresher'])) + ->and(ApiModelCache::getCacheStatus())->toBe('MISS') + ->and($store->get('api_query_version:orders:company-1'))->toBe(2); +}); + +test('api model cache does not serve model or relationship lookups cached before an invalidation when the tag flush leaves them behind', function () { + $store = api_model_cache_fixture(); + $store->flushRemovesValues = false; + $model = api_model_cache_model(); + + ApiModelCache::cacheModel($model, 'order-1', fn () => ['status' => 'stale'], ['payload']); + ApiModelCache::cacheRelationship($model, 'payload', fn () => ['name' => 'stale']); + $modelKeyBefore = ApiModelCache::generateModelCacheKey($model, 'order-1', ['payload']); + $relationKeyBefore = ApiModelCache::generateRelationshipCacheKey($model, 'payload'); + + ApiModelCache::invalidateModelCache($model, 'company-1'); + + expect(ApiModelCache::cacheModel($model, 'order-1', fn () => ['status' => 'fresh'], ['payload']))->toBe(['status' => 'fresh']) + ->and(ApiModelCache::getCacheStatus())->toBe('MISS') + ->and(ApiModelCache::getCacheKey())->not->toBe($modelKeyBefore) + ->and(ApiModelCache::cacheRelationship($model, 'payload', fn () => ['name' => 'fresh']))->toBe(['name' => 'fresh']) + ->and(ApiModelCache::getCacheStatus())->toBe('MISS') + ->and(ApiModelCache::getCacheKey())->not->toBe($relationKeyBefore) + ->and($store->get('api_model_version:orders'))->toBe(1); + + // Lookups know no company, so a write in any company moves the table's model version + ApiModelCache::invalidateModelCache($model, null); + + expect(ApiModelCache::cacheModel($model, 'order-1', fn () => ['status' => 'fresher'], ['payload']))->toBe(['status' => 'fresher']) + ->and(ApiModelCache::cacheRelationship($model, 'payload', fn () => ['name' => 'fresher']))->toBe(['name' => 'fresher']) + ->and($store->get('api_model_version:orders'))->toBe(2); +}); + +test('api model cache resolves declared dependencies to related pivot and raw table names', function () { + api_model_cache_dependency_database(); + config(['api.cache.strict_dependencies' => true]); + + expect(ApiModelCache::dependencyTables(new ApiModelCacheDependencyOrder()))->toBe(['audit_logs', 'order_tags', 'payloads', 'tags']) + ->and(ApiModelCache::dependencyTables(new ApiModelCacheRelationsOrder()))->toBe([]) + // Lookup paths skip what they cannot resolve instead of failing the lookup + ->and(ApiModelCache::dependencyTables(new ApiModelCacheRelationsOrder(), ['payload', 'subject', 'missing', 'tags.missing'])) + ->toBe(['order_tags', 'payloads', 'tags']) + ->and(fn () => ApiModelCache::dependencyTables(new ApiModelCacheMorphDependencyOrder())) + ->toThrow(LogicException::class, 'entry "subject" cannot be resolved at "subject"') + ->and(fn () => ApiModelCache::dependencyTables(new ApiModelCacheUnresolvableDependencyOrder())) + ->toThrow(LogicException::class, 'entry "payload.customer" cannot be resolved at "customer"') + ->and(fn () => ApiModelCache::dependencyTables(new ApiModelCacheNotRelationDependencyOrder())) + ->toThrow(LogicException::class, 'entry "summary" cannot be resolved at "summary"'); +}); + +test('api model cache logs and skips an unresolvable declaration unless strict', function () { + api_model_cache_dependency_database(); + config(['api.cache.strict_dependencies' => false]); + $logger = new ApiModelCacheTestLogger(); + app()->instance('log', $logger); + Facade::clearResolvedInstance('log'); + + expect(ApiModelCache::dependencyTables(new ApiModelCachePartlyResolvableDependencyOrder()))->toBe(['order_tags', 'payloads', 'tags']) + ->and($logger->errors)->toHaveCount(1) + ->and($logger->errors[0]['message'])->toContain('entry "payload.customer" cannot be resolved at "customer"'); +}); + +test('api model cache never calls a method a client names unless it is declared to return a relation', function () { + api_model_cache_dependency_database(); + config(['api.cache.strict_dependencies' => false]); + ApiModelCacheRelationsOrder::$sideEffects = 0; + $order = new ApiModelCacheRelationsOrder(); + + expect(ApiModelCache::dependencyTables($order, ['dispatch', 'untypedPayload', 'untyped_payload', 'exploding', 'payload']))->toBe(['payloads']) + ->and(ApiModelCacheRelationsOrder::$sideEffects)->toBe(0) + // A declaration comes from the model's own code and may name an untyped relation + ->and(ApiModelCache::dependencyTables(new ApiModelCacheUntypedDependencyOrder()))->toBe(['payloads']); + + // A list request naming them still caches and versions what it can + $request = api_model_cache_request(['with' => ['dispatch', 'exploding', 'payload']]); + expect(ApiModelCache::cacheQueryResult($order, $request, fn () => collect(['listed'])))->toEqual(collect(['listed'])) + ->and(ApiModelCache::getCacheStatus())->toBe('MISS') + ->and(ApiModelCacheRelationsOrder::$sideEffects)->toBe(0); +}); + +test('api model cache contains a declared relation that throws on a blank model', function () { + api_model_cache_dependency_database(); + config(['api.cache.strict_dependencies' => false]); + $logger = new ApiModelCacheTestLogger(); + app()->instance('log', $logger); + Facade::clearResolvedInstance('log'); + + expect(ApiModelCache::dependencyTables(new ApiModelCacheExplodingDependencyOrder()))->toBe(['order_tags', 'tags']) + ->and($logger->errors)->toHaveCount(1) + ->and($logger->errors[0]['message'])->toContain('entry "exploding" cannot be resolved'); +}); + +test('api model cache invalidation never fails the write when the cache is unavailable', function () { + $store = api_model_cache_fixture(); + $store->throwOnIncrement = true; + $store->throwOnTags = true; + $logger = new ApiModelCacheTestLogger(); + app()->instance('log', $logger); + Facade::clearResolvedInstance('log'); + + ApiModelCache::invalidateModelCache(api_model_cache_model(), 'company-1'); + + expect($logger->errors)->toHaveCount(2) + ->and($logger->errors[0]['message'])->toBe('Failed to advance API cache versions') + ->and($logger->errors[0]['context']['keys'])->toBe(['api_query_version:orders:company-1', 'api_model_version:orders']) + ->and($logger->errors[1]['message'])->toBe('Failed to invalidate cache'); +}); + +test('api model cache skips invalidation inside a withoutInvalidating scope only', function () { + $store = api_model_cache_fixture(); + $model = api_model_cache_model(); + + $result = ApiModelCache::withoutInvalidating(function () use ($model) { + ApiModelCache::invalidateModelCache($model, 'company-1'); + // Nested scopes stay suppressed until the outermost one ends + ApiModelCache::withoutInvalidating(fn () => ApiModelCache::invalidateModelCache($model, 'company-1')); + ApiModelCache::invalidateModelCache($model, 'company-1'); + + return 'written'; + }); + + expect($result)->toBe('written') + ->and($store->get('api_query_version:orders:company-1'))->toBeNull(); + + // A callback that throws still ends the scope + expect(fn () => ApiModelCache::withoutInvalidating(fn () => throw new RuntimeException('write failed')))->toThrow(RuntimeException::class); + + ApiModelCache::invalidateModelCache($model, 'company-1'); + expect($store->get('api_query_version:orders:company-1'))->toBe(1); +}); + +test('api model cache falls back to the query when reading versions fails', function () { + $store = api_model_cache_fixture(); + $store->throwOnMany = true; + $model = api_model_cache_model(); + + expect(ApiModelCache::cacheQueryResult($model, api_model_cache_request(['status' => 'active']), fn () => collect(['direct'])))->toEqual(collect(['direct'])) + ->and(ApiModelCache::getCacheStatus())->toBe('MISS') + ->and($store->taggedValues)->toBe([]); +}); + +test('api model cache retires a list cached while the invalidating write was uncommitted', function () { + $capsule = api_model_cache_dependency_database(); + $store = app('cache'); + $store->flushRemovesValues = false; + $connection = $capsule->getConnection('mysql'); + $transactions = new DatabaseTransactionsManager(); + $connection->setTransactionManager($transactions); + app()->instance('db.transactions', $transactions); + $order = new ApiModelCacheDependencyOrder(); + $request = api_model_cache_request(['status' => 'active']); + + $connection->beginTransaction(); + ApiModelCache::invalidateModelCache(new ApiModelCacheDependencyPayload(), 'company-1'); + + // Another request reads before the commit and caches the old rows under the new version + ApiModelCache::cacheQueryResult($order, $request, fn () => collect(['uncommitted view'])); + expect($store->get('api_query_version:payloads:company-1'))->toBe(1); + + $connection->commit(); + + expect($store->get('api_query_version:payloads:company-1'))->toBe(2) + ->and(ApiModelCache::cacheQueryResult($order, $request, fn () => collect(['committed'])))->toEqual(collect(['committed'])); + + // The transaction may be on another connection than the written model's + $capsule->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => ''], 'other'); + $other = $capsule->getConnection('other'); + $other->setTransactionManager($transactions); + $other->beginTransaction(); + ApiModelCache::invalidateModelCache(new ApiModelCacheDependencyPayload(), 'company-1'); + ApiModelCache::cacheQueryResult($order, $request, fn () => collect(['uncommitted elsewhere'])); + $other->commit(); + + expect(ApiModelCache::cacheQueryResult($order, $request, fn () => collect(['committed elsewhere'])))->toEqual(collect(['committed elsewhere'])); + + // A rolled back write leaves only its immediate bump + $connection->beginTransaction(); + ApiModelCache::invalidateModelCache(new ApiModelCacheDependencyPayload(), 'company-1'); + $connection->rollBack(); + + expect($store->get('api_query_version:payloads:company-1'))->toBe(5); + + app()->forgetInstance('db.transactions'); +}); + +test('api model cache keeps the immediate bump when the transactions manager cannot be resolved', function () { + $capsule = api_model_cache_dependency_database(); + $store = app('cache'); + $store->flushRemovesValues = false; + $connection = $capsule->getConnection('mysql'); + app()->bind('db.transactions', fn () => throw new RuntimeException('transactions manager unavailable')); + + $connection->beginTransaction(); + ApiModelCache::invalidateModelCache(new ApiModelCacheDependencyPayload(), 'company-1'); + $connection->commit(); + + app()->offsetUnset('db.transactions'); + + expect($store->get('api_query_version:payloads:company-1'))->toBe(1); +}); + +test('api model cache only resolves public instance relation methods without required parameters', function () { + api_model_cache_dependency_database(); + $order = new ApiModelCacheUncallableRelationsOrder(); + + expect(ApiModelCache::dependencyTables($order, ['static_payload', 'payload_for', 'hidden_payload']))->toBe([]) + ->and(ApiModelCache::dependencyTables($order, ['payload']))->toBe(['payloads']); +}); + +test('api model cache clears the relation path memo once it reaches its limit', function () { + api_model_cache_dependency_database(); + $memo = new ReflectionProperty(ApiModelCache::class, 'dependencyTables'); + $limit = (new ReflectionClassConstant(ApiModelCache::class, 'DEPENDENCY_MEMO_LIMIT'))->getValue(); + $memo->setAccessible(true); + $memo->setValue(null, array_fill_keys(array_map(fn ($i) => "memo-{$i}", range(1, $limit)), [])); + + expect(ApiModelCache::dependencyTables(new ApiModelCacheRelationsOrder(), ['payload']))->toBe(['payloads']) + ->and($memo->getValue())->toHaveCount(1); +}); + +test('api model cache resolves request style relation paths without side effects on the model', function () { + api_model_cache_dependency_database(); + $order = new ApiModelCacheRelationsOrder(); + + expect(ApiModelCache::dependencyTables($order, ['tag_payloads']))->toBe(['payloads', 'tags']) + // Framework methods named by a client are never called + ->and(ApiModelCache::dependencyTables($order, ['delete', 'push', 'touch', 'getTable']))->toBe([]) + ->and(ApiModelCache::dependencyTables($order, ['reconnecting_payload']))->toBe(['payloads']) + ->and($order->getConnectionName())->toBeNull(); +}); + +test('api model cache versions a list by the relations its request eager loads', function () { + api_model_cache_dependency_database(); + $store = app('cache'); + $store->flushRemovesValues = false; + $order = new ApiModelCacheRelationsOrder(); + $withPayload = api_model_cache_request(['with' => ['payload']]); + $expandTags = api_model_cache_request(['expand' => 'tags']); + $plain = api_model_cache_request(); + + ApiModelCache::cacheQueryResult($order, $withPayload, fn () => collect(['stale payload'])); + ApiModelCache::cacheQueryResult($order, $expandTags, fn () => collect(['stale tags'])); + ApiModelCache::cacheQueryResult($order, $plain, fn () => collect(['plain'])); + + ApiModelCache::invalidateModelCache(new ApiModelCacheDependencyPayload(), 'company-1'); + + expect(ApiModelCache::cacheQueryResult($order, $withPayload, fn () => collect(['fresh payload'])))->toEqual(collect(['fresh payload'])) + ->and(ApiModelCache::cacheQueryResult($order, $expandTags, fn () => collect(['unexpected'])))->toEqual(collect(['stale tags'])) + ->and(ApiModelCache::cacheQueryResult($order, $plain, fn () => collect(['unexpected'])))->toEqual(collect(['plain'])); + + ApiModelCache::invalidateModelCache(new ApiModelCacheDependencyOrderTag(), null); + + expect(ApiModelCache::cacheQueryResult($order, $expandTags, fn () => collect(['fresh tags'])))->toEqual(collect(['fresh tags'])) + ->and(ApiModelCache::cacheQueryResult($order, $plain, fn () => collect(['unexpected'])))->toEqual(collect(['plain'])); +}); + +test('api model cache does not serve a list after a write to a declared dependency of the same company or of no company', function () { + api_model_cache_dependency_database(); + $store = app('cache'); + $store->flushRemovesValues = false; + $order = new ApiModelCacheDependencyOrder(); + $request = api_model_cache_request(['status' => 'active']); + + ApiModelCache::cacheQueryResult($order, $request, fn () => collect(['stale'])); + + // Another company's write leaves this company's list alone + ApiModelCache::invalidateModelCache(new ApiModelCacheDependencyPayload(), 'company-2'); + + expect(ApiModelCache::cacheQueryResult($order, $request, fn () => collect(['unexpected'])))->toEqual(collect(['stale'])) + ->and(ApiModelCache::getCacheStatus())->toBe('HIT'); + + ApiModelCache::invalidateModelCache(new ApiModelCacheDependencyPayload(), 'company-1'); + + expect(ApiModelCache::cacheQueryResult($order, $request, fn () => collect(['fresh'])))->toEqual(collect(['fresh'])) + ->and(ApiModelCache::getCacheStatus())->toBe('MISS'); + + // Pivots carry no company, so their writes move the table wide version every company reads + ApiModelCache::invalidateModelCache(new ApiModelCacheDependencyOrderTag(), null); + + expect(ApiModelCache::cacheQueryResult($order, $request, fn () => collect(['fresher'])))->toEqual(collect(['fresher'])) + ->and(ApiModelCache::getCacheStatus())->toBe('MISS') + // orders, then audit_logs, order_tags, payloads, tags; each table wide then company-1 + ->and(ApiModelCache::getCacheKey())->toStartWith('{api_query}:orders:company_company-1:v0.0.0.0.1.0.0.1.0.0:'); +}); + +test('has api model cache invalidates a parent list when a dependency or custom pivot is written through eloquent', function () { + api_model_cache_dependency_database(); + $store = app('cache'); + $store->flushRemovesValues = false; + + Model::setEventDispatcher(new Dispatcher(app())); + Model::clearBootedModels(); + + $request = api_model_cache_request(['status' => 'active']); + $read = fn (string $value) => ApiModelCache::cacheQueryResult(new ApiModelCacheDependencyOrder(), $request, fn () => collect([$value])); + + $read('before payload write'); + $payload = ApiModelCacheDependencyPayload::create(['uuid' => 'payload-1', 'company_uuid' => 'company-1', 'name' => 'Payload']); + + expect($read('after payload create'))->toEqual(collect(['after payload create'])); + + $payload->update(['name' => 'Renamed']); + + expect($read('after payload update'))->toEqual(collect(['after payload update'])); + + $order = ApiModelCacheDependencyOrder::find('order-1'); + $order->tags()->attach('tag-1'); + + expect($read('after attach'))->toEqual(collect(['after attach'])); + + $order->tags()->detach('tag-1'); + + expect($read('after detach'))->toEqual(collect(['after detach'])) + ->and($read('unexpected'))->toEqual(collect(['after detach'])); + + Model::unsetEventDispatcher(); +}); + +test('api model cache does not serve relationship or eager loaded lookups after a write to the related table', function () { + api_model_cache_dependency_database(); + $store = app('cache'); + $store->flushRemovesValues = false; + $order = new ApiModelCacheRelationsOrder(['uuid' => 'order-1']); + + ApiModelCache::cacheRelationship($order, 'payload', fn () => ['name' => 'stale']); + ApiModelCache::cacheModel($order, 'order-1', fn () => ['payload' => 'stale'], ['payload:uuid,name']); + ApiModelCache::cacheModel($order, 'order-1', fn () => ['status' => 'cached']); + + ApiModelCache::invalidateModelCache(new ApiModelCacheDependencyPayload(), 'company-1'); + + expect(ApiModelCache::cacheRelationship($order, 'payload', fn () => ['name' => 'fresh']))->toBe(['name' => 'fresh']) + ->and(ApiModelCache::getCacheKey())->toBe('{api_relation}:orders:v0.1:order-1:payload') + ->and(ApiModelCache::cacheModel($order, 'order-1', fn () => ['payload' => 'fresh'], ['payload:uuid,name']))->toBe(['payload' => 'fresh']) + // A lookup that does not load the payload keeps its cache + ->and(ApiModelCache::cacheModel($order, 'order-1', fn () => ['status' => 'unexpected']))->toBe(['status' => 'cached']); +}); + +test('api model cache seeds versions on stores whose increment does not create missing keys', function () { + $store = api_model_cache_fixture(); + $store->flushRemovesValues = false; + $store->incrementCreatesMissing = false; + $model = api_model_cache_model(); + $request = api_model_cache_request(['status' => 'active']); + + ApiModelCache::cacheQueryResult($model, $request, fn () => collect(['stale'])); + ApiModelCache::invalidateModelCache($model, 'company-1'); + + expect($store->get('api_query_version:orders:company-1'))->toBe(1) + ->and($store->get('api_model_version:orders'))->toBe(1) + ->and(ApiModelCache::cacheQueryResult($model, $request, fn () => collect(['fresh'])))->toEqual(collect(['fresh'])); + + ApiModelCache::invalidateModelCache($model, 'company-1'); + + expect($store->get('api_query_version:orders:company-1'))->toBe(2) + ->and($store->get('api_model_version:orders'))->toBe(2); +}); + +test('api model cache keeps lists of storefront stores and networks of the same company apart', function () { + api_model_cache_fixture(); + $model = api_model_cache_model(); + $query = ['limit' => '10']; + $callback = ['has_callback' => true]; + $storeA = api_model_cache_scoped_request('/storefront/v1/products', ['company' => 'company-1', 'storefront_store' => 'store-a', 'api_credential' => 'store_key_a'], $query); + $storeB = api_model_cache_scoped_request('/storefront/v1/products', ['company' => 'company-1', 'storefront_store' => 'store-b', 'api_credential' => 'store_key_b'], $query); + $network = api_model_cache_scoped_request('/storefront/v1/products', ['company' => 'company-1', 'storefront_network' => 'network-a', 'api_credential' => 'network_key_a'], $query); + + expect(ApiModelCache::cacheQueryResult($model, $storeA, fn () => collect(['store a product']), $callback))->toEqual(collect(['store a product'])); + $storeAKey = ApiModelCache::getCacheKey(); + + expect(ApiModelCache::cacheQueryResult($model, $storeB, fn () => collect(['store b product']), $callback))->toEqual(collect(['store b product'])) + ->and(ApiModelCache::getCacheStatus())->toBe('MISS') + ->and(ApiModelCache::cacheQueryResult($model, $network, fn () => collect(['network product']), $callback))->toEqual(collect(['network product'])) + ->and(ApiModelCache::getCacheStatus())->toBe('MISS') + ->and(ApiModelCache::cacheQueryResult($model, $storeA, fn () => collect(['not reached']), $callback))->toEqual(collect(['store a product'])) + ->and(ApiModelCache::getCacheStatus())->toBe('HIT') + ->and($storeAKey)->toStartWith('{api_query}:orders:company_company-1:v0.0:') + ->and($storeAKey)->not->toContain('store_key_a'); +}); + +test('api model cache keeps internal and public lists of the same company and query apart', function () { + api_model_cache_fixture(); + $model = api_model_cache_model(); + $query = ['limit' => '30']; + $callback = ['has_callback' => true]; + $internal = api_model_cache_scoped_request('/storefront/int/v1/catalogs', ['company' => 'company-1'], $query); + $public = api_model_cache_scoped_request('/storefront/v1/catalogs', ['company' => 'company-1', 'storefront_store' => 'store-a', 'api_credential' => 'store_key_a'], $query); + $console = api_model_cache_scoped_request('/storefront/int/v1/catalogs', ['company' => 'company-1'], $query); + + ApiModelCache::cacheQueryResult($model, $internal, fn () => 'internal paginator with editor relations', $callback); + + expect(ApiModelCache::cacheQueryResult($model, $public, fn () => collect(['store a catalog']), $callback))->toEqual(collect(['store a catalog'])) + ->and(ApiModelCache::getCacheStatus())->toBe('MISS') + ->and(ApiModelCache::cacheQueryResult($model, $console, fn () => 'not reached', $callback))->toBe('internal paginator with editor relations') + ->and(ApiModelCache::getCacheStatus())->toBe('HIT'); +}); + +test('api model cache keeps public lists of the same company and query on different endpoints apart', function () { + api_model_cache_fixture(); + $model = api_model_cache_model(); + $session = ['company' => 'company-1', 'api_credential' => 'credential-1']; + $key = fn (string $path) => ApiModelCache::generateQueryCacheKey($model, api_model_cache_scoped_request($path, $session, ['limit' => '10']), ['has_callback' => true]); + + expect($key('/v1/customers/customer_a/places'))->not->toBe($key('/v1/customers/customer_b/places')) + ->and($key('/v1/customers/customer_a/places'))->toBe($key('/v1/customers/customer_a/places')); +}); + +test('api model cache scopes list keys by the configured session keys only', function () { + api_model_cache_fixture(['api.cache.scope.session' => ['customer']]); + $model = api_model_cache_model(); + $key = fn (array $session) => ApiModelCache::generateQueryCacheKey($model, api_model_cache_scoped_request('/v1/orders', ['company' => 'company-1', ...$session])); + + expect($key(['customer' => 'customer-a']))->not->toBe($key(['customer' => 'customer-b'])) + ->and($key(['customer' => 'customer-a', 'storefront_store' => 'store-a']))->toBe($key(['customer' => 'customer-a', 'storefront_store' => 'store-b'])); +}); + +test('api model cache keys requests without a session by company input and leaves them unscoped', function () { + api_model_cache_fixture(); + $model = api_model_cache_model(); + + $sessionless = ApiModelCache::generateQueryCacheKey($model, Request::create('/v1/orders', 'GET', [ + 'status' => 'active', + 'company_uuid' => 'company-1', + ])); + $unscoped = ApiModelCache::generateQueryCacheKey($model, api_model_cache_scoped_request('/v1/orders', [], [ + 'status' => 'active', + 'company_uuid' => 'company-1', + ])); + + expect($sessionless)->toStartWith('{api_query}:orders:company_company-1:v0.0:') + ->and($sessionless)->toBe($unscoped); +}); + test('api model cache resolves tenant scope from authenticated user and request input fallbacks', function () { api_model_cache_fixture(); $model = api_model_cache_model(); @@ -594,9 +1265,9 @@ public function gc(int $max_lifetime): int|false ], null); expect(ApiModelCache::generateQueryCacheKey($model, $userRequest)) - ->toStartWith('{api_query}:orders:company_company-from-user:v1:') + ->toStartWith('{api_query}:orders:company_company-from-user:v0.0:') ->and(ApiModelCache::generateQueryCacheKey($model, $inputRequest)) - ->toStartWith('{api_query}:orders:company_company-from-input:v1:'); + ->toStartWith('{api_query}:orders:company_company-from-input:v0.0:'); }); test('api model cache invalidation methods swallow tagged cache backend failures', function () { @@ -632,13 +1303,13 @@ public function gc(int $max_lifetime): int|false expect($modelResult)->toBe(['fallback' => true]) ->and(ApiModelCache::getCacheStatus())->toBe('ERROR') - ->and(ApiModelCache::getCacheKey())->toBe('{api_model}:orders:order-1'); + ->and(ApiModelCache::getCacheKey())->toBe('{api_model}:orders:v0:order-1'); $relationshipResult = ApiModelCache::cacheRelationship($model, 'payload', fn () => ['relationship' => true]); expect($relationshipResult)->toBe(['relationship' => true]) ->and(ApiModelCache::getCacheStatus())->toBe('ERROR') - ->and(ApiModelCache::getCacheKey())->toBe('{api_relation}:orders:order-1:payload'); + ->and(ApiModelCache::getCacheKey())->toBe('{api_relation}:orders:v0:order-1:payload'); }); test('api model cache warmup logs failures without leaking exceptions', function () { @@ -686,7 +1357,7 @@ public function gc(int $max_lifetime): int|false ->and($second->pluck('uuid')->all())->toBe(['order-2']) ->and($callbackCalls)->toBe(1) ->and(ApiModelCache::getCacheStatus())->toBe('HIT') - ->and(ApiModelCache::getCacheKey())->toStartWith('{api_query}:orders:company_company-1:v1:') + ->and(ApiModelCache::getCacheKey())->toStartWith('{api_query}:orders:company_company-1:v0.0:') ->and(ApiModelCacheTraitTestModel::$mutatedRequestQueries)->toHaveCount(1) ->and(ApiModelCacheTraitTestModel::$mutatedRequestQueries[0])->toMatchArray([ 'status' => 'active', diff --git a/tests/Unit/Support/NotificationRegistryTest.php b/tests/Unit/Support/NotificationRegistryTest.php index 4bfd0d96..f83d9c80 100644 --- a/tests/Unit/Support/NotificationRegistryTest.php +++ b/tests/Unit/Support/NotificationRegistryTest.php @@ -116,6 +116,47 @@ class NotificationRegistryDispatchPropertySubject extends EloquentModel public $timestamps = false; } +class NotificationRegistrySideEffectSubject extends EloquentModel +{ + public static array $calls = []; + + protected $primaryKey = 'uuid'; + + public $incrementing = false; + + protected $keyType = 'string'; + + public $timestamps = false; + + public function dispatch(): void + { + static::$calls[] = $this->uuid ?? 'new-instance'; + } +} + +class NotificationRegistryLoadingSubject extends NotificationRegistrySideEffectSubject +{ + // Mirrors fleetops Order::resolveDynamicNotifiable(), which loads any property it is given + public function resolveDynamicNotifiable(string $property): mixed + { + if (!$this->relationLoaded($property)) { + $this->load($property); + } + + return $this->{$property}; + } +} + +class NotificationRegistryConstructorProbe +{ + public static int $constructed = 0; + + public function __construct() + { + static::$constructed++; + } +} + class NotificationRegistryDispatchCacheFake { private array $values = []; @@ -218,8 +259,12 @@ function notification_registry_dispatch_database(): Capsule Fleetbase\Models\Group::class, Fleetbase\Models\Role::class, Fleetbase\Models\Company::class, + NotificationRegistryDispatchTarget::class, + NotificationRegistryDispatchGroup::class, ]; - NotificationRegistryDispatchTarget::$sent = []; + NotificationRegistryDispatchTarget::$sent = []; + NotificationRegistrySideEffectSubject::$calls = []; + NotificationRegistryConstructorProbe::$constructed = 0; }); afterEach(function () { @@ -321,6 +366,7 @@ function notification_registry_dispatch_database(): Capsule session(['company' => 'company-1']); NotificationRegistry::register(NotificationRegistryDispatchNotification::class); + NotificationRegistry::registerNotifiable('dynamic:assignee'); Fleetbase\Models\Setting::query()->create([ 'key' => 'company.company-1.notification_settings', @@ -379,6 +425,7 @@ function notification_registry_dispatch_database(): Capsule test('notification registry dispatches by definition name with dynamic subject context', function () { notification_registry_dispatch_database(); + NotificationRegistry::registerNotifiable('dynamic:dispatcher'); Fleetbase\Models\Setting::query()->create([ 'key' => 'company.company-1.notification_settings', @@ -505,6 +552,7 @@ function notification_registry_dispatch_database(): Capsule test('notification registry resolves dynamic notifiables from subject properties', function () { notification_registry_dispatch_database(); + NotificationRegistry::registerNotifiable('dynamic:assignee'); Fleetbase\Models\Setting::query()->create([ 'key' => 'company.company-1.notification_settings', @@ -565,6 +613,106 @@ function notification_registry_dispatch_database(): Capsule expect(NotificationRegistryDispatchTarget::$sent)->toBe([]); }); +function notification_registry_settings_with(array $notifiables): void +{ + Fleetbase\Models\Setting::query()->create([ + 'key' => 'company.company-1.notification_settings', + 'value' => [ + 'notificationRegistryDispatchNotification__manualNotice' => [ + 'notifiables' => [ + ...$notifiables, + [ + 'definition' => NotificationRegistryDispatchTarget::class, + 'primaryKey' => 'uuid', + 'key' => 'target-2', + ], + ], + ], + ], + ]); +} + +test('notification registry recognises only registered notifiable definitions', function () { + NotificationRegistry::registerNotifiable('dynamic:driver'); + + expect(NotificationRegistry::isRegisteredNotifiable('dynamic:driver'))->toBeTrue() + ->and(NotificationRegistry::isRegisteredNotifiable(Fleetbase\Models\User::class))->toBeTrue() + ->and(NotificationRegistry::isRegisteredNotifiable('\\' . Fleetbase\Models\User::class))->toBeTrue() + ->and(NotificationRegistry::isRegisteredNotifiable('dynamic:dispatch'))->toBeFalse() + ->and(NotificationRegistry::isRegisteredNotifiable('dynamic:Driver'))->toBeFalse() + ->and(NotificationRegistry::isRegisteredNotifiable(NotificationRegistryConstructorProbe::class))->toBeFalse() + ->and(NotificationRegistry::isRegisteredNotifiable(''))->toBeFalse() + ->and(NotificationRegistry::isRegisteredNotifiable(null))->toBeFalse() + ->and(NotificationRegistry::isRegisteredNotifiable(['dynamic:driver']))->toBeFalse(); +}); + +test('notification registry does not read an unregistered dynamic property from the subject', function () { + notification_registry_dispatch_database(); + notification_registry_settings_with([ + ['definition' => 'dynamic:dispatch', 'primaryKey' => 'uuid', 'key' => 'ignored-for-dynamic'], + ]); + + $subject = new NotificationRegistrySideEffectSubject(); + $subject->uuid = 'subject-side-effect'; + $subject->company_uuid = 'company-1'; + + NotificationRegistry::notifyUsingDefinitionName(NotificationRegistryDispatchNotification::class, 'Manual Notice', $subject, 'skipped'); + + expect(NotificationRegistrySideEffectSubject::$calls)->toBe([]) + ->and(array_column(NotificationRegistryDispatchTarget::$sent, 'target'))->toBe(['target-2']); +}); + +test('notification registry does not pass an unregistered dynamic property to the subject resolver', function () { + notification_registry_dispatch_database(); + notification_registry_settings_with([ + ['definition' => 'dynamic:dispatch', 'primaryKey' => 'uuid', 'key' => 'ignored-for-dynamic'], + ]); + + $subject = new NotificationRegistryLoadingSubject(); + $subject->uuid = 'subject-loading'; + $subject->company_uuid = 'company-1'; + + NotificationRegistry::notifyUsingDefinitionName(NotificationRegistryDispatchNotification::class, 'Manual Notice', $subject, 'skipped'); + + expect(NotificationRegistrySideEffectSubject::$calls)->toBe([]) + ->and(array_column(NotificationRegistryDispatchTarget::$sent, 'target'))->toBe(['target-2']); +}); + +test('notification registry still resolves a registered dynamic property through the subject resolver', function () { + notification_registry_dispatch_database(); + NotificationRegistry::registerNotifiable('dynamic:dispatcher'); + notification_registry_settings_with([ + ['definition' => 'dynamic:dispatcher', 'primaryKey' => 'uuid', 'key' => 'ignored-for-dynamic'], + ]); + + $subject = new NotificationRegistryDispatchSubject(); + $subject->uuid = 'subject-registered'; + $subject->company_uuid = 'company-1'; + + NotificationRegistry::notifyUsingDefinitionName(NotificationRegistryDispatchNotification::class, 'Manual Notice', $subject, 'registered'); + + expect(array_column(NotificationRegistryDispatchTarget::$sent, 'target'))->toBe(['target-1', 'target-2']); +}); + +test('notification registry does not build an unregistered notifiable class', function () { + notification_registry_dispatch_database(); + NotificationRegistry::$notifiables = array_values(array_diff(NotificationRegistry::$notifiables, [NotificationRegistryDispatchGroup::class])); + notification_registry_settings_with([ + ['definition' => NotificationRegistryConstructorProbe::class, 'primaryKey' => 'uuid', 'key' => 'probe'], + ['definition' => NotificationRegistryDispatchGroup::class, 'primaryKey' => 'uuid', 'key' => 'group-1'], + ['definition' => ['not', 'a', 'class'], 'primaryKey' => 'uuid', 'key' => 'array'], + ]); + + $subject = new NotificationRegistryDispatchSubject(); + $subject->uuid = 'subject-class'; + $subject->company_uuid = 'company-1'; + + NotificationRegistry::notifyUsingDefinitionName(NotificationRegistryDispatchNotification::class, 'Manual Notice', $subject, 'class'); + + expect(NotificationRegistryConstructorProbe::$constructed)->toBe(0) + ->and(array_column(NotificationRegistryDispatchTarget::$sent, 'target'))->toBe(['target-2']); +}); + function notification_registry_company_settings(string $companyUuid, string $settingsKey, string $targetUuid = 'target-1'): void { Fleetbase\Models\Setting::query()->create([ diff --git a/tests/Unit/Support/RequestRelationsTest.php b/tests/Unit/Support/RequestRelationsTest.php new file mode 100644 index 00000000..f0c817bc --- /dev/null +++ b/tests/Unit/Support/RequestRelationsTest.php @@ -0,0 +1,214 @@ +hasMany(RequestRelationsChild::class, 'parent_id'); + } + + public function nullableOwner(): ?BelongsTo + { + return $this->belongsTo(RequestRelationsChild::class, 'owner_id'); + } + + public function scopedOwner(): BelongsTo|Builder + { + return $this->belongsTo(RequestRelationsChild::class, 'owner_id'); + } + + public function subject(): MorphTo + { + return $this->morphTo(); + } + + public function brokenChildren(): HasMany + { + throw new RuntimeException('relation cannot be built on a blank model'); + } + + public function untypedChildren() + { + return $this->hasMany(RequestRelationsChild::class, 'parent_id'); + } + + public function legacyChildren() + { + return $this->hasMany(RequestRelationsChild::class, 'parent_id'); + } + + public function dispatch(bool $save = true) + { + static::$calls++; + + return $this; + } + + public function typedChildrenFor(string $type): HasMany + { + return $this->hasMany(RequestRelationsChild::class, 'parent_id')->where('type', $type); + } + + public static function staticChildren(): HasMany + { + return (new static())->hasMany(RequestRelationsChild::class, 'parent_id'); + } + + protected function hiddenChildren(): HasMany + { + return $this->hasMany(RequestRelationsChild::class, 'parent_id'); + } +} + +class RequestRelationsChild extends EloquentModel +{ + public static int $calls = 0; + + protected $table = 'request_relations_children'; + + public function parent(): BelongsTo + { + return $this->belongsTo(RequestRelationsParent::class, 'parent_id'); + } + + public function untypedParent() + { + return $this->belongsTo(RequestRelationsParent::class, 'parent_id'); + } + + public function cancel() + { + static::$calls++; + + return $this; + } +} + +class RequestRelationsExpansion +{ + public static function target(): string + { + return RequestRelationsParent::class; + } + + public static function expandedChildren() + { + return function (): HasMany { + /* @var RequestRelationsParent $this */ + return $this->hasMany(RequestRelationsChild::class, 'parent_id'); + }; + } + + public static function untypedExpandedChildren() + { + return function () { + /* @var RequestRelationsParent $this */ + return $this->hasMany(RequestRelationsChild::class, 'parent_id'); + }; + } +} + +beforeEach(function () { + bind_test_container(); + + $capsule = new Capsule(app()); + $capsule->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + + RequestRelationsParent::expand(new RequestRelationsExpansion()); + RequestRelationsParent::$calls = 0; + RequestRelationsChild::$calls = 0; +}); + +afterEach(function () { + EloquentModel::unsetConnectionResolver(); + EloquentModel::clearBootedModels(); +}); + +test('request relations accept methods that declare a relation return type', function () { + $model = new RequestRelationsParent(); + + expect(RequestRelations::eagerLoads($model, ['children', 'nullable_owner', 'scopedOwner', 'subject'])) + ->toBe(['children', 'nullableOwner', 'scopedOwner', 'subject']) + ->and(RequestRelations::eagerLoads($model, 'children, nullable_owner'))->toBe(['children', 'nullableOwner']) + ->and(RequestRelations::eagerLoads($model, ['nullableowner', 'legacychildren']))->toBe(['nullableOwner', 'legacyChildren']) + ->and(RequestRelations::counts($model, ['children', 'scoped_owner']))->toBe(['children', 'scopedOwner']); +}); + +test('request relations drop untyped methods unless the model lists them as withable', function () { + $model = new RequestRelationsParent(); + + expect(RequestRelations::eagerLoads($model, ['untypedChildren', 'legacy_children']))->toBe(['legacyChildren']) + ->and(RequestRelations::counts($model, ['untypedChildren', 'legacyChildren']))->toBe(['legacyChildren']); +}); + +test('request relations never call a method that is not a relation', function () { + $model = new RequestRelationsParent(); + $requested = ['dispatch', 'save', 'push', 'touch', 'delete', 'fresh', 'replicate', 'toArray', 'getKey', 'children.cancel', 'children.parent.dispatch']; + + expect(RequestRelations::eagerLoads($model, $requested))->toBe(['children', 'children.parent']) + ->and(RequestRelations::counts($model, $requested))->toBe([]) + ->and(RequestRelationsParent::$calls)->toBe(0) + ->and(RequestRelationsChild::$calls)->toBe(0); +}); + +test('request relations drop static protected and argument taking methods', function () { + expect(RequestRelations::eagerLoads(new RequestRelationsParent(), ['staticChildren', 'hiddenChildren', 'typedChildrenFor']))->toBe([]); +}); + +test('request relations drop a typed relation that cannot be built instead of failing the request', function () { + expect(RequestRelations::eagerLoads(new RequestRelationsParent(), ['brokenChildren', 'children']))->toBe(['children']) + ->and(RequestRelations::counts(new RequestRelationsParent(), ['brokenChildren']))->toBe([]); +}); + +test('request relations check each nested segment against the related model', function () { + $model = new RequestRelationsParent(); + + expect(RequestRelations::eagerLoads($model, ['children.parent.children', 'children.untyped_parent', 'children.missing.parent'])) + ->toBe(['children.parent.children', 'children']) + ->and(RequestRelations::eagerLoads($model, ['children.parent.children.parent.children.parent.children'])) + ->toBe(['children.parent.children.parent.children']) + ->and(RequestRelations::counts($model, ['children.parent']))->toBe([]); +}); + +test('request relations stop a path at a morph to and never count one', function () { + $model = new RequestRelationsParent(); + + expect(RequestRelations::eagerLoads($model, ['subject.dispatch', 'subject.children']))->toBe(['subject']) + ->and(RequestRelations::counts($model, ['subject']))->toBe([]); +}); + +test('request relations accept expansion closures by the same rule', function () { + $model = new RequestRelationsParent(); + + expect(RequestRelations::eagerLoads($model, ['expandedChildren', 'untypedExpandedChildren', 'expanded_children.parent'])) + ->toBe(['expandedChildren', 'expandedChildren.parent']); +}); + +test('request relations ignore malformed input and cap the number of paths', function () { + $model = new RequestRelationsParent(); + + expect(RequestRelations::eagerLoads($model, ['children:id,name', 'children items', '', ' ', ['children'], 42, null, 'children..parent'])) + ->toBe(['children']) + ->and(RequestRelations::eagerLoads($model, null))->toBe([]) + ->and(RequestRelations::eagerLoads($model, 42))->toBe([]) + ->and(RequestRelations::eagerLoads($model, array_merge(array_map(fn ($i) => "missing{$i}", range(1, RequestRelations::MAX_PATHS)), ['children'])))->toBe([]); +}); diff --git a/tests/Unit/Support/Sms/PhoneValidatorTest.php b/tests/Unit/Support/Sms/PhoneValidatorTest.php new file mode 100644 index 00000000..fcdc85d3 --- /dev/null +++ b/tests/Unit/Support/Sms/PhoneValidatorTest.php @@ -0,0 +1,70 @@ +toMatchArray(['valid' => true, 'e164' => '+447400100123', 'country' => 'GB', 'type' => 'MOBILE', 'reason' => null]) + ->and($mn)->toMatchArray(['valid' => true, 'e164' => '+97699112233', 'country' => 'MN', 'type' => 'MOBILE']) + ->and($us)->toMatchArray(['valid' => true, 'e164' => '+14155552671', 'country' => 'US', 'type' => 'FIXED_LINE_OR_MOBILE']); +}); + +test('phone validator does not guess a country for a number without one', function () { + $local = PhoneValidator::validate('(415) 555-2671'); + + expect($local['valid'])->toBeFalse() + ->and($local['reason'])->toBe(PhoneValidator::REASON_INVALID); +}); + +test('phone validator normalises formatting and international prefixes', function () { + expect(PhoneValidator::normalize('0044 7400 100123'))->toBe('+447400100123') + ->and(PhoneValidator::normalize('+1 (415) 555-2671'))->toBe('+14155552671') + ->and(PhoneValidator::normalize('97699112233'))->toBe('+97699112233') + ->and(PhoneValidator::normalize('+'))->toBe('') + ->and(PhoneValidator::normalize(''))->toBe('') + ->and(PhoneValidator::validate('+14155552671')['e164'])->toBe('+14155552671'); +}); + +test('phone validator refuses numbers that are invalid or cannot receive texts', function () { + $short = PhoneValidator::validate('+4471'); + $garbage = PhoneValidator::validate('not-a-number'); + $premium = PhoneValidator::validate('+449098790123'); + $land = PhoneValidator::validate('+442079460000'); + + expect($short)->toMatchArray(['valid' => false, 'reason' => PhoneValidator::REASON_INVALID]) + ->and($garbage)->toMatchArray(['valid' => false, 'e164' => null, 'reason' => PhoneValidator::REASON_INVALID]) + ->and($premium)->toMatchArray(['valid' => false, 'type' => 'PREMIUM_RATE', 'reason' => PhoneValidator::REASON_TYPE_NOT_ALLOWED]) + ->and($land)->toMatchArray(['valid' => false, 'type' => 'FIXED_LINE', 'reason' => PhoneValidator::REASON_TYPE_NOT_ALLOWED]); +}); + +test('phone validator applies blocked prefixes, blocked countries and a company allowlist', function () { + $prefix = PhoneValidator::validate('+447400100123', ['blocked_prefixes' => '+800, +4474']); + $country = PhoneValidator::validate('+447400100123', ['blocked_countries' => 'gb,fr']); + $allowed = PhoneValidator::validate('+447400100123', ['allowed_countries' => ['MN', 'SG']]); + $fine = PhoneValidator::validate('+447400100123', ['blocked_prefixes' => ['+800'], 'blocked_countries' => ['FR'], 'allowed_countries' => ['GB']]); + + expect($prefix['reason'])->toBe(PhoneValidator::REASON_PREFIX_BLOCKED) + ->and($country['reason'])->toBe(PhoneValidator::REASON_COUNTRY_BLOCKED) + ->and($allowed['reason'])->toBe(PhoneValidator::REASON_COUNTRY_NOT_ALLOWED) + ->and($fine['valid'])->toBeTrue(); +}); + +test('phone validator only normalises when validation is disabled', function () { + $invalid = PhoneValidator::validate('+4471', ['enabled' => false]); + $premium = PhoneValidator::validate('+449098790123', ['enabled' => false, 'blocked_prefixes' => ['+44']]); + + expect($invalid)->toMatchArray(['valid' => true, 'e164' => '+4471', 'reason' => null]) + ->and($premium)->toMatchArray(['valid' => true, 'e164' => '+449098790123', 'type' => 'PREMIUM_RATE', 'reason' => null]); +}); + +test('phone validator explains refusals without naming the rule', function () { + expect(PhoneValidator::messageFor(PhoneValidator::REASON_INVALID))->toBe('The phone number provided is not valid.') + ->and(PhoneValidator::messageFor(PhoneValidator::REASON_TYPE_NOT_ALLOWED))->toBe('The phone number provided cannot receive text messages.') + ->and(PhoneValidator::messageFor(PhoneValidator::REASON_PREFIX_BLOCKED))->toBe('Text messages cannot be sent to this phone number.') + ->and(PhoneValidator::messageFor(PhoneValidator::REASON_COUNTRY_NOT_ALLOWED))->toBe('Text messages cannot be sent to this phone number.') + ->and(PhoneValidator::typeName(1))->toBe('MOBILE') + ->and(PhoneValidator::typeName(999))->toBe('UNKNOWN'); +}); diff --git a/tests/Unit/Support/Sms/SmsGuardLedgerTest.php b/tests/Unit/Support/Sms/SmsGuardLedgerTest.php new file mode 100644 index 00000000..3b25f9fd --- /dev/null +++ b/tests/Unit/Support/Sms/SmsGuardLedgerTest.php @@ -0,0 +1,260 @@ + */ + public array $alerts = []; + + protected function dispatchAlert(SmsGuardAlert $alert): void + { + $this->alerts[] = $alert; + } +} + +/** + * The guard with a real ledger and verification code table behind it. + * + * @return array{0: SmsGuard, 1: SmsGuardLedgerTestBreaker, 2: Capsule} + */ +function sms_guard_ledger_setup(array $guard = []): array +{ + EloquentModel::clearBootedModels(); + session()->flush(); + + $connection = [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]; + + $config = SmsPolicy::merge([ + 'enabled' => true, + 'mode' => 'enforce', + 'validation' => ['enabled' => true], + 'limits' => [ + 'destination' => ['cooldown_seconds' => 0, 'per_hour' => null, 'per_day' => null], + 'ip' => ['per_15_minutes' => 500, 'per_day' => 500], + 'company' => ['per_minute' => 500, 'per_hour' => 500, 'per_day' => 500], + 'probation' => ['days' => 0], + 'platform' => ['public' => ['per_minute' => 500, 'per_hour' => 500, 'per_day' => 500]], + ], + 'breaker' => [ + 'alert_percentages' => [], + 'distinct_destinations_per_10m' => 500, + 'new_country_min_sends' => 3, + 'new_country_min_history' => 5, + 'conversion_min_issued' => 20, + 'conversion_min_percent' => 10, + ], + ], $guard); + + $container = bind_test_container([ + 'sms.guard' => $config, + 'database.default' => 'mysql', + 'database.connections.mysql' => $connection, + 'fleetbase.connection.db' => 'mysql', + ]); + $cache = new CacheRepository(new ArrayStore()); + $container->instance('cache', $cache); + $container->instance('request', Request::create('/storefront/v1/customers/request-creation-code', 'POST', [], [], [], ['REMOTE_ADDR' => '203.0.113.9'])); + Facade::clearResolvedInstances(); + session(['company' => 'company-1', 'storefront_store' => 'store-1']); + + $capsule = new Capsule($container); + $capsule->addConnection($connection, 'mysql'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + $capsule->getDatabaseManager()->setDefaultConnection('mysql'); + $container->instance('db', $capsule->getDatabaseManager()); + Facade::clearResolvedInstance('db'); + + SmsGuardFixture::smsSendsSchema($capsule); + SmsGuardFixture::verificationCodesSchema($capsule); + + $breaker = new SmsGuardLedgerTestBreaker($cache); + + return [new SmsGuard($cache, $breaker), $breaker, $capsule]; +} + +function sms_guard_ledger_seed_sent(Capsule $capsule, string $company, string $country, int $count): void +{ + $rows = []; + foreach (range(1, $count) as $i) { + $rows[] = [ + 'uuid' => (string) Str::uuid(), + 'company_uuid' => $company, + 'purpose' => 'storefront_create_customer', + 'source' => 'storefront', + 'destination' => '+4474001' . str_pad((string) $i, 5, '0', STR_PAD_LEFT), + 'destination_country' => $country, + 'status' => 'sent', + 'created_at' => Carbon::now()->subDay(), + 'updated_at' => Carbon::now()->subDay(), + ]; + } + $capsule->getConnection('mysql')->table('sms_sends')->insert($rows); +} + +afterEach(function () { + Carbon::setTestNow(); + session()->flush(); + EloquentModel::unsetConnectionResolver(); + EloquentModel::clearBootedModels(); + Facade::clearResolvedInstances(); +}); + +test('an allowed send is reserved on the ledger and settled with the provider outcome', function () { + [$guard] = sms_guard_ledger_setup(); + + $reservation = $guard->reserve(SmsContext::resolve('+44 7400 100123', ['purpose' => 'storefront_create_customer'])); + $guard->settle($reservation, true, ['provider' => 'twilio', 'message_id' => 'SM1']); + + $failed = $guard->reserve(SmsContext::resolve('+447400100124', ['purpose' => 'storefront_create_customer'])); + $guard->settle($failed, false, ['provider' => 'twilio', 'error' => 'carrier refused']); + + $sent = SmsSend::where('uuid', $reservation->sendUuid)->first(); + $lost = SmsSend::where('uuid', $failed->sendUuid)->first(); + + expect($reservation->sendUuid)->toBeString() + ->and($reservation->trace()['sms_send_uuid'])->toBe($reservation->sendUuid) + ->and($sent->status)->toBe('sent') + ->and($sent->company_uuid)->toBe('company-1') + ->and($sent->tenant_uuid)->toBe('store-1') + ->and($sent->destination)->toBe('+447400100123') + ->and($sent->destination_country)->toBe('GB') + ->and($sent->destination_type)->toBe('MOBILE') + ->and($sent->provider_message_id)->toBe('SM1') + ->and($sent->ip_address)->toBe('203.0.113.9') + ->and($sent->meta)->toBe(['decision' => 'allowed']) + ->and($lost->status)->toBe('failed') + ->and($lost->error)->toBe('carrier refused'); +}); + +test('a refused send is written to the ledger as blocked with its reason', function () { + [$guard] = sms_guard_ledger_setup(); + + try { + $guard->reserve(SmsContext::resolve('+4471', ['purpose' => 'storefront_create_customer'])); + } catch (SmsBlockedException $e) { + // expected + } + + $guard->breaker()->pause('company:company-1', 30, 'incident'); + try { + $guard->reserve(SmsContext::resolve('+447400100123', ['purpose' => 'storefront_create_customer'])); + } catch (SmsBlockedException $e) { + // expected + } + + $rows = SmsSend::orderBy('created_at')->get(); + + expect($rows)->toHaveCount(2) + ->and($rows[0]->status)->toBe('blocked') + ->and($rows[0]->block_reason)->toBe('invalid_destination') + ->and($rows[0]->destination)->toBe('+4471') + ->and($rows[1]->status)->toBe('blocked') + ->and($rows[1]->block_reason)->toBe('paused') + ->and($rows[1]->company_uuid)->toBe('company-1'); +}); + +test('a burst of sends to a country the tenant has never texted pauses it', function () { + [$guard, $breaker, $capsule] = sms_guard_ledger_setup(); + + // Not armed yet: no history. Three sends to a new country go through. + foreach (['+97699112201', '+97699112202', '+97699112203'] as $number) { + $guard->reserve(SmsContext::resolve($number, ['purpose' => 'storefront_create_customer'])); + } + expect($breaker->alerts)->toBe([]) + ->and($breaker->pausedUntil('tenant:store:store-1'))->toBeNull(); + + // With history in GB, the same burst to Mongolia trips the breaker on the third send. + sms_guard_ledger_seed_sent($capsule, 'company-1', 'GB', 6); + Illuminate\Support\Facades\Cache::forget('sms-guard:countries:company-1'); + Illuminate\Support\Facades\Cache::forget('sms-guard:sent-count:company-1'); + + $guard->reserve(SmsContext::resolve('+97699112211', ['purpose' => 'storefront_create_customer'])); + $guard->reserve(SmsContext::resolve('+97699112212', ['purpose' => 'storefront_create_customer'])); + $guard->reserve(SmsContext::resolve('+97699112213', ['purpose' => 'storefront_create_customer'])); + + $refused = null; + try { + $guard->reserve(SmsContext::resolve('+97699112214', ['purpose' => 'storefront_create_customer'])); + } catch (SmsBlockedException $e) { + $refused = $e; + } + + // A known country is still fine for the company's other store. + session(['company' => 'company-1', 'storefront_store' => 'store-2']); + $known = $guard->reserve(SmsContext::resolve('+447400100199', ['purpose' => 'storefront_create_customer'])); + + expect($breaker->alerts)->toHaveCount(1) + ->and($breaker->alerts[0]->signal)->toBe(SmsBreaker::SIGNAL_NEW_COUNTRY) + ->and($breaker->alerts[0]->data['country'])->toBe('MN') + ->and($breaker->alerts[0]->data['known_countries'])->toBe(['GB']) + ->and($breaker->alerts[0]->toArray(null)['title'])->toBe('SMS to a country new to the tenant') + ->and($breaker->pausedUntil('tenant:store:store-1')['reason'])->toBe(SmsBreaker::SIGNAL_NEW_COUNTRY) + ->and($refused?->status())->toBe(423) + ->and($known->decision)->toBe('allowed'); +}); + +test('codes that are sent by sms but never verified pause the company', function () { + [$guard, $breaker, $capsule] = sms_guard_ledger_setup(); + $codes = []; + foreach (range(1, 20) as $i) { + $codes[] = [ + 'uuid' => 'code-' . $i, + 'company_uuid' => 'company-1', + 'channel' => 'sms', + 'code' => '123456', + 'for' => 'storefront_create_customer', + 'status' => 'active', + 'expires_at' => Carbon::now()->addMinutes(10), + 'consumed_at' => $i <= 1 ? Carbon::now() : null, + 'created_at' => Carbon::now()->subMinutes(5), + 'updated_at' => Carbon::now()->subMinutes(5), + ]; + } + $capsule->getConnection('mysql')->table('verification_codes')->insert($codes); + + $guard->reserve(SmsContext::resolve('+447400100123', ['purpose' => 'storefront_create_customer'])); + + $refused = null; + try { + $guard->reserve(SmsContext::resolve('+447400100124', ['purpose' => 'storefront_create_customer'])); + } catch (SmsBlockedException $e) { + $refused = $e; + } + + expect($breaker->alerts)->toHaveCount(1) + ->and($breaker->alerts[0]->signal)->toBe(SmsBreaker::SIGNAL_LOW_CONVERSION) + ->and($breaker->alerts[0]->data)->toMatchArray(['issued_last_hour' => 20, 'verified_last_hour' => 1]) + ->and($breaker->alerts[0]->toArray(null)['title'])->toBe('SMS codes going unverified') + ->and($breaker->pausedUntil('company:company-1'))->not->toBeNull() + ->and($refused?->status())->toBe(423); + + // Healthy conversion trips nothing. + [$healthyGuard, $healthyBreaker, $healthyCapsule] = sms_guard_ledger_setup(); + $healthyCapsule->getConnection('mysql')->table('verification_codes')->insert(array_map(fn ($code) => array_merge($code, ['consumed_at' => Carbon::now()]), $codes)); + $healthyGuard->reserve(SmsContext::resolve('+447400100123', ['purpose' => 'storefront_create_customer'])); + + expect($healthyBreaker->alerts)->toBe([]); +}); diff --git a/tests/Unit/Support/Sms/SmsGuardTest.php b/tests/Unit/Support/Sms/SmsGuardTest.php new file mode 100644 index 00000000..31c6f0b7 --- /dev/null +++ b/tests/Unit/Support/Sms/SmsGuardTest.php @@ -0,0 +1,358 @@ + */ + public array $alerts = []; + + protected function dispatchAlert(SmsGuardAlert $alert): void + { + $this->alerts[] = $alert; + } +} + +class SmsGuardTestBrokenCache extends CacheRepository +{ + public function add($key, $value, $ttl = null) + { + throw new RuntimeException('redis down'); + } +} + +/** + * @return array{0: SmsGuard, 1: SmsGuardTestBreaker, 2: CacheRepository} + */ +function sms_guard_test_setup(array $guard = [], string $path = '/storefront/v1/customers/request-creation-code', ?CacheRepository $cache = null): array +{ + session()->flush(); + + $config = SmsPolicy::merge([ + 'enabled' => true, + 'mode' => 'enforce', + 'alert_emails' => '', + 'validation' => ['enabled' => true, 'blocked_prefixes' => '+881,+882'], + 'limits' => [ + 'destination' => ['cooldown_seconds' => 60, 'per_hour' => 3, 'per_day' => 5], + 'ip' => ['per_15_minutes' => 50, 'per_day' => 100], + 'company' => ['per_minute' => 100, 'per_hour' => 100, 'per_day' => 100], + 'tenant' => ['per_minute' => null, 'per_hour' => null, 'per_day' => null], + 'probation' => ['days' => 0, 'per_day' => 20], + 'platform' => [ + 'public' => ['per_minute' => 500, 'per_hour' => 500, 'per_day' => 500], + 'trusted' => ['per_minute' => 500, 'per_hour' => 500, 'per_day' => 500], + ], + ], + 'breaker' => [ + 'pause_minutes' => 30, + 'alert_percentages' => [50], + 'distinct_destinations_per_10m' => 50, + 'auto_pauses_before_manual' => 2, + ], + 'purposes' => ['2fa' => ['trusted' => true]], + ], $guard); + + $container = bind_test_container(['sms.guard' => $config]); + $cache ??= new CacheRepository(new ArrayStore()); + $container->instance('cache', $cache); + $container->instance('request', Request::create($path, 'POST', [], [], [], ['REMOTE_ADDR' => '203.0.113.9', 'HTTP_USER_AGENT' => 'StorefrontApp/3.1'])); + Facade::clearResolvedInstances(); + session(['company' => 'company-1', 'storefront_store' => 'store-1', 'storefront_network' => null]); + + $breaker = new SmsGuardTestBreaker($cache); + + return [new SmsGuard($cache, $breaker), $breaker, $cache]; +} + +function sms_guard_context(string $destination, string $purpose = 'storefront_create_customer'): SmsContext +{ + return SmsContext::resolve($destination, ['purpose' => $purpose]); +} + +function sms_guard_refusal(SmsGuard $guard, SmsContext $context): SmsBlockedException +{ + try { + $guard->reserve($context); + } catch (SmsBlockedException $e) { + return $e; + } + + throw new RuntimeException('Expected the guard to refuse ' . $context->destination); +} + +afterEach(function () { + Carbon::setTestNow(); + session()->flush(); + Facade::clearResolvedInstances(); +}); + +test('sms context is resolved from the session and request', function () { + sms_guard_test_setup(); + + $context = sms_guard_context('+447400100123'); + + expect($context->companyUuid)->toBe('company-1') + ->and($context->tenantType)->toBe('store') + ->and($context->tenantUuid)->toBe('store-1') + ->and($context->source)->toBe(SmsContext::SOURCE_STOREFRONT) + ->and($context->ip)->toBe('203.0.113.9') + ->and($context->userAgent)->toBe('StorefrontApp/3.1') + ->and($context->isPublic())->toBeTrue() + ->and($context->companyScope())->toBe('company:company-1') + ->and($context->tenantScope())->toBe('tenant:store:store-1') + ->and($context->withDestination('+15550001111')->destination)->toBe('+15550001111') + ->and(SmsContext::detectSource(Request::create('/v1/drivers/login-with-sms')))->toBe(SmsContext::SOURCE_API) + ->and(SmsContext::detectSource(Request::create('/int/v1/onboard/send-verification-sms')))->toBe(SmsContext::SOURCE_PUBLIC) + ->and(SmsContext::detectSource(Request::create('/int/v1/two-fa/resend')))->toBe(SmsContext::SOURCE_CONSOLE) + ->and(SmsContext::detectSource(Request::create('/public/track/ABC/codes')))->toBe(SmsContext::SOURCE_PUBLIC) + ->and(SmsContext::detectSource(Request::create('/')))->toBe(SmsContext::SOURCE_SYSTEM) + ->and(SmsContext::detectSource(null))->toBe(SmsContext::SOURCE_SYSTEM); +}); + +test('sms guard is bypassed entirely when disabled', function () { + [$guard] = sms_guard_test_setup(['enabled' => false]); + + $reservation = $guard->reserve(sms_guard_context('not-a-number')); + + expect($reservation->decision)->toBe(SmsReservation::DECISION_BYPASSED) + ->and($reservation->isGuarded())->toBeFalse() + ->and($reservation->trace())->toBe([]); +}); + +test('sms guard refuses destinations that can never be texted with a 422', function () { + [$guard] = sms_guard_test_setup(); + + $invalid = sms_guard_refusal($guard, sms_guard_context('+4471')); + $premium = sms_guard_refusal($guard, sms_guard_context('+449098790123')); + $blocked = sms_guard_refusal($guard, sms_guard_context('+88216123456789')); + + expect($invalid->status())->toBe(422) + ->and($invalid->reason())->toBe('invalid_destination') + ->and($invalid->headers())->toBe([]) + ->and($premium->status())->toBe(422) + ->and($premium->reason())->toBe('type_not_allowed') + ->and($blocked->status())->toBe(422) + ->and($blocked->reason())->toBeIn(['prefix_blocked', 'type_not_allowed', 'invalid_destination']) + ->and($guard->limiter()->attempts(SmsGuard::CACHE_PREFIX . 'company:company-1:per_day'))->toBe(0); +}); + +test('sms guard enforces the per-destination cooldown and caps with a retry-after', function () { + [$guard] = sms_guard_test_setup(['limits' => ['destination' => ['cooldown_seconds' => 60, 'per_hour' => 2, 'per_day' => 5]]]); + Carbon::setTestNow(Carbon::parse('2026-10-13 10:00:00', 'UTC')); + + $first = $guard->reserve(sms_guard_context('+44 7400 100123')); + $again = sms_guard_refusal($guard, sms_guard_context('+447400100123')); + + Carbon::setTestNow(Carbon::parse('2026-10-13 10:01:01', 'UTC')); + $second = $guard->reserve(sms_guard_context('+447400100123')); + + Carbon::setTestNow(Carbon::parse('2026-10-13 10:02:02', 'UTC')); + $third = sms_guard_refusal($guard, sms_guard_context('+447400100123')); + $other = $guard->reserve(sms_guard_context('+447400100124')); + + expect($first->decision)->toBe(SmsReservation::DECISION_ALLOWED) + ->and($first->context->destination)->toBe('+447400100123') + ->and($first->country)->toBe('GB') + ->and($first->type)->toBe('MOBILE') + ->and($again->status())->toBe(429) + ->and($again->reason())->toBe('cooldown') + ->and($again->retryAfter())->toBeGreaterThanOrEqual(1) + ->and($again->headers())->toHaveKey('Retry-After') + ->and($second->decision)->toBe(SmsReservation::DECISION_ALLOWED) + ->and($third->reason())->toBe('destination_limit') + ->and($third->status())->toBe(429) + ->and($other->decision)->toBe(SmsReservation::DECISION_ALLOWED) + ->and($guard->limiter()->attempts(SmsGuard::CACHE_PREFIX . 'company:company-1:per_day'))->toBe(3) + ->and($guard->limiter()->attempts(SmsGuard::CACHE_PREFIX . 'platform:public:per_day'))->toBe(3); +}); + +test('sms guard hits the company budget, pauses the company and alerts the admins once', function () { + [$guard, $breaker] = sms_guard_test_setup([ + 'limits' => ['destination' => ['cooldown_seconds' => 0, 'per_hour' => null, 'per_day' => null], 'company' => ['per_minute' => 100, 'per_hour' => 100, 'per_day' => 3]], + 'breaker' => ['alert_percentages' => []], + ]); + + $guard->reserve(sms_guard_context('+447400100001')); + $guard->reserve(sms_guard_context('+447400100002')); + $third = $guard->reserve(sms_guard_context('+447400100003')); + $paused = sms_guard_refusal($guard, sms_guard_context('+447400100004')); + $pause = $breaker->pausedUntil('company:company-1'); + + // Staff 2FA for the paused company still goes out: trusted purposes skip tenant pauses + // and the company budget, and count in their own platform bucket. + $twoFa = $guard->reserve(sms_guard_context('+447400100005', '2fa')); + + // Another tenant is untouched. + session(['company' => 'company-2', 'storefront_store' => 'store-9']); + $otherTenant = $guard->reserve(sms_guard_context('+447400100006')); + + expect($third->decision)->toBe(SmsReservation::DECISION_ALLOWED) + ->and($paused->status())->toBe(423) + ->and($paused->reason())->toBe(SmsBlockedException::REASON_PAUSED) + ->and($paused->retryAfter())->toBeGreaterThan(0) + ->and($paused->context()['scope'])->toBe('company:company-1') + ->and($pause['auto'])->toBeTrue() + ->and($pause['reason'])->toBe(SmsBreaker::SIGNAL_COMPANY_DAILY_BUDGET) + ->and($pause['until'])->toBeGreaterThan(time()) + ->and($breaker->alerts)->toHaveCount(1) + ->and($breaker->alerts[0]->signal)->toBe(SmsBreaker::SIGNAL_COMPANY_DAILY_BUDGET) + ->and($breaker->alerts[0]->data['sends_today'])->toBe(3) + ->and($breaker->alerts[0]->toArray(null)['title'])->toBe('tenant hit its daily SMS budget') + ->and($twoFa->decision)->toBe(SmsReservation::DECISION_ALLOWED) + ->and($guard->limiter()->attempts(SmsGuard::CACHE_PREFIX . 'platform:trusted:per_day'))->toBe(1) + ->and($otherTenant->decision)->toBe(SmsReservation::DECISION_ALLOWED); +}); + +test('sms guard alerts at the configured share of the daily budget without pausing', function () { + [$guard, $breaker] = sms_guard_test_setup([ + 'limits' => ['destination' => ['cooldown_seconds' => 0, 'per_hour' => null, 'per_day' => null], 'company' => ['per_minute' => 100, 'per_hour' => 100, 'per_day' => 4]], + 'breaker' => ['alert_percentages' => [50]], + ]); + + $guard->reserve(sms_guard_context('+447400100001')); + $guard->reserve(sms_guard_context('+447400100002')); + $guard->reserve(sms_guard_context('+447400100003')); + + expect($breaker->alerts)->toHaveCount(1) + ->and($breaker->alerts[0]->signal)->toBe(SmsBreaker::SIGNAL_BUDGET_THRESHOLD) + ->and($breaker->alerts[0]->data['percent'])->toBe(50) + ->and($breaker->pausedUntil('company:company-1'))->toBeNull(); +}); + +test('sms guard pauses a store that texts a burst of distinct numbers, and escalates repeat pauses', function () { + [$guard, $breaker] = sms_guard_test_setup([ + 'limits' => ['destination' => ['cooldown_seconds' => 0, 'per_hour' => null, 'per_day' => null]], + 'breaker' => ['distinct_destinations_per_10m' => 2, 'alert_percentages' => []], + ]); + + $guard->reserve(sms_guard_context('+447400100001')); + $guard->reserve(sms_guard_context('+447400100002')); + $refused = sms_guard_refusal($guard, sms_guard_context('+447400100003')); + $first = $breaker->pausedUntil('tenant:store:store-1'); + + // The company itself is not paused, so its other store keeps working. + session(['company' => 'company-1', 'storefront_store' => 'store-2']); + $sibling = $guard->reserve(sms_guard_context('+447400100007')); + + session(['company' => 'company-1', 'storefront_store' => 'store-1']); + $breaker->unpause('tenant:store:store-1'); + $guard->reserve(sms_guard_context('+447400100003')); + $second = $breaker->pausedUntil('tenant:store:store-1'); + + expect($refused->status())->toBe(423) + ->and($refused->context()['scope'])->toBe('tenant:store:store-1') + ->and($first['reason'])->toBe(SmsBreaker::SIGNAL_DISTINCT_DESTINATIONS) + ->and($first['until'])->not->toBeNull() + ->and($breaker->pausedUntil('company:company-1'))->toBeNull() + ->and($sibling->decision)->toBe(SmsReservation::DECISION_ALLOWED) + ->and($second)->not->toBeNull() + ->and($second['until'])->toBeNull() + ->and($second['reason'])->toContain('administrator must unpause') + ->and($breaker->alerts)->toHaveCount(1) + ->and($breaker->alerts[0]->signal)->toBe(SmsBreaker::SIGNAL_DISTINCT_DESTINATIONS); +}); + +test('sms guard in monitor mode lets everything through and records what it would have refused', function () { + [$guard, $breaker] = sms_guard_test_setup([ + 'mode' => 'monitor', + 'limits' => ['destination' => ['cooldown_seconds' => 60, 'per_hour' => null, 'per_day' => null]], + 'breaker' => ['alert_percentages' => [], 'distinct_destinations_per_10m' => 2], + ]); + + $invalid = $guard->reserve(sms_guard_context('+4471')); + $first = $guard->reserve(sms_guard_context('+447400100123')); + $cooldown = $guard->reserve(sms_guard_context('+447400100123')); + $entries = collect(app('log')->entries)->pluck(1)->all(); + + expect($invalid->decision)->toBe(SmsReservation::DECISION_MONITOR) + ->and($invalid->wouldBlock)->toBe('invalid_destination') + ->and($first->decision)->toBe(SmsReservation::DECISION_ALLOWED) + ->and($cooldown->decision)->toBe(SmsReservation::DECISION_MONITOR) + ->and($cooldown->wouldBlock)->toBe('cooldown') + ->and($entries)->toContain('sms.would_block') + ->and($entries)->not->toContain('sms.blocked') + ->and($breaker->pausedUntil('tenant:store:store-1'))->toBeNull() + ->and($breaker->pausedUntil('company:company-1'))->toBeNull() + ->and($breaker->alerts)->toHaveCount(1) + ->and($breaker->alerts[0]->data['mode'])->toBe('monitor') + ->and($breaker->alerts[0]->toArray(null)['lines'])->toContain('The guard is in monitor mode, so nothing was paused.'); +}); + +test('sms guard fails closed for public sends and open for trusted ones when the counters are unreachable', function () { + [$guard] = sms_guard_test_setup([], '/storefront/v1/customers/request-creation-code', new SmsGuardTestBrokenCache(new ArrayStore())); + + $public = sms_guard_refusal($guard, sms_guard_context('+447400100123')); + $trusted = $guard->reserve(sms_guard_context('+447400100123', '2fa')); + + [$consoleGuard] = sms_guard_test_setup([], '/int/v1/users/send-verification', new SmsGuardTestBrokenCache(new ArrayStore())); + $console = $consoleGuard->reserve(sms_guard_context('+447400100123', 'phone_verification')); + + [$monitorGuard] = sms_guard_test_setup(['mode' => 'monitor'], '/storefront/v1/customers/request-creation-code', new SmsGuardTestBrokenCache(new ArrayStore())); + $monitored = $monitorGuard->reserve(sms_guard_context('+447400100123')); + + expect($public->status())->toBe(503) + ->and($public->reason())->toBe(SmsBlockedException::REASON_UNAVAILABLE) + ->and($trusted->decision)->toBe(SmsReservation::DECISION_DEGRADED) + ->and($console->decision)->toBe(SmsReservation::DECISION_DEGRADED) + ->and($monitored->decision)->toBe(SmsReservation::DECISION_DEGRADED); +}); + +test('sms guard counters skip ip and tenant layers for trusted purposes and keep guard options from providers', function () { + [$guard] = sms_guard_test_setup(['limits' => ['tenant' => ['per_minute' => null, 'per_hour' => null, 'per_day' => 40]]]); + + $public = collect($guard->counters(sms_guard_context('+447400100123'), SmsPolicy::limitsFor(sms_guard_context('+447400100123')))); + $trusted = collect($guard->counters(sms_guard_context('+447400100123', '2fa'), SmsPolicy::limitsFor(sms_guard_context('+447400100123', '2fa')))); + + expect($public->pluck('scope')->unique()->values()->all())->toBe(['destination', 'ip', 'tenant', 'company', 'platform']) + ->and($public->firstWhere('scope', 'tenant')['key'])->toBe(SmsGuard::CACHE_PREFIX . 'tenant:store:store-1:per_day') + ->and($public->firstWhere('scope', 'platform')['key'])->toBe(SmsGuard::CACHE_PREFIX . 'platform:public:per_minute') + ->and($trusted->pluck('scope')->unique()->values()->all())->toBe(['destination', 'platform']) + ->and($trusted->firstWhere('scope', 'platform')['key'])->toBe(SmsGuard::CACHE_PREFIX . 'platform:trusted:per_minute') + ->and(SmsGuard::providerOptions(['purpose' => 'x', 'sms_reservation' => 'r', 'twilioParams' => ['from' => 'ACME'], 'from' => 'ACME']))->toBe(['twilioParams' => ['from' => 'ACME'], 'from' => 'ACME']); +}); + +test('sms guard settle logs the outcome once per reservation', function () { + [$guard] = sms_guard_test_setup(); + + $reservation = $guard->reserve(sms_guard_context('+447400100123')); + $guard->settle($reservation, true, ['provider' => 'twilio', 'message_id' => 'SM1']); + $guard->settle($reservation, false, ['provider' => 'twilio', 'error' => 'ignored, already settled']); + + $failed = $guard->reserve(sms_guard_context('+447400100124')); + $guard->settle($failed, false, ['provider' => 'twilio', 'error' => 'carrier refused']); + + $bypassed = new SmsReservation(sms_guard_context('+447400100125'), SmsReservation::DECISION_BYPASSED); + $guard->settle($bypassed, true); + + $entries = collect(app('log')->entries); + + expect($entries->where(1, 'sms.sent'))->toHaveCount(1) + ->and($entries->where(1, 'sms.sent')->first()[2]['message_id'])->toBe('SM1') + ->and($entries->where(1, 'sms.failed'))->toHaveCount(1) + ->and($entries->where(1, 'sms.failed')->first()[2]['error'])->toBe('carrier refused') + ->and($reservation->settled)->toBeTrue() + ->and($bypassed->settled)->toBeTrue(); +}); + +test('sms guard make returns a bound instance when the container has one', function () { + [$guard] = sms_guard_test_setup(); + app()->instance(SmsGuard::class, $guard); + + expect(SmsGuard::make())->toBe($guard); + + app()->forgetInstance(SmsGuard::class); + + expect(SmsGuard::make())->not->toBe($guard); +}); diff --git a/tests/Unit/Support/Sms/SmsLedgerTest.php b/tests/Unit/Support/Sms/SmsLedgerTest.php new file mode 100644 index 00000000..192d17c7 --- /dev/null +++ b/tests/Unit/Support/Sms/SmsLedgerTest.php @@ -0,0 +1,122 @@ + 'GB', 'destination_type' => 'MOBILE', 'meta' => ['decision' => 'allowed']]); + SmsLedger::update($uuid, ['status' => SmsSend::STATUS_SENT, 'provider' => 'twilio', 'provider_message_id' => 'SM1']); + SmsLedger::update(null, ['status' => 'ignored']); + + $row = SmsSend::where('uuid', $uuid)->first(); + + expect($uuid)->toBeString() + ->and($row->company_uuid)->toBe('company-1') + ->and($row->tenant_type)->toBe('store') + ->and($row->tenant_uuid)->toBe('store-1') + ->and($row->purpose)->toBe('storefront_create_customer') + ->and($row->source)->toBe('storefront') + ->and($row->destination)->toBe('+447400100123') + ->and($row->destination_hash)->toBe(hash('sha256', '+447400100123')) + ->and($row->destination_country)->toBe('GB') + ->and($row->status)->toBe('sent') + ->and($row->provider)->toBe('twilio') + ->and($row->provider_message_id)->toBe('SM1') + ->and($row->ip_address)->toBe('203.0.113.9') + ->and($row->user_agent)->toBe('StorefrontApp/3.1') + ->and($row->meta)->toBe(['decision' => 'allowed']) + ->and(SmsSend::count())->toBe(1); +}); + +test('sms ledger answers country and volume questions from sent rows only', function () { + SmsGuardFixture::database(); + + foreach ([['GB', 'sent'], ['GB', 'sent'], ['MN', 'sent'], ['US', 'blocked'], ['FR', 'failed']] as [$country, $status]) { + SmsLedger::record(sms_ledger_context(), $status, ['destination_country' => strtolower($country)]); + } + SmsLedger::record(sms_ledger_context('+15550001111', 'company-2'), 'sent', ['destination_country' => 'US']); + + expect(SmsLedger::countriesFor('company-1'))->toEqualCanonicalizing(['GB', 'MN']) + ->and(SmsLedger::sentCountFor('company-1'))->toBe(3) + ->and(SmsLedger::sentCountFor('company-2'))->toBe(1) + ->and(SmsLedger::countriesFor('company-none'))->toBe([]); +}); + +test('sms ledger deletes rows past retention and masks the number on older surviving rows', function () { + SmsGuardFixture::database(); + Carbon::setTestNow(Carbon::parse('2026-10-13 12:00:00', 'UTC')); + + $fresh = SmsLedger::record(sms_ledger_context('+447400100001'), 'sent'); + $old = SmsLedger::record(sms_ledger_context('+447400100002'), 'sent'); + $gone = SmsLedger::record(sms_ledger_context('+447400100003'), 'sent'); + SmsSend::where('uuid', $old)->update(['created_at' => Carbon::now()->subDays(45)]); + SmsSend::where('uuid', $gone)->update(['created_at' => Carbon::now()->subDays(120)]); + + $result = SmsLedger::prune(90, 30); + $again = SmsLedger::prune(90, 30); + + expect($result)->toBe(['masked' => 1, 'deleted' => 1]) + ->and($again)->toBe(['masked' => 0, 'deleted' => 0]) + ->and(SmsSend::where('uuid', $fresh)->value('destination'))->toBe('+447400100001') + ->and(SmsSend::where('uuid', $old)->value('destination'))->toBe('•••0002') + ->and(SmsSend::where('uuid', $old)->value('destination_hash'))->toBe(hash('sha256', '+447400100002')) + ->and(SmsSend::where('uuid', $gone)->exists())->toBeFalse() + ->and(SmsLedger::mask('+1 (415) 555-2671'))->toBe('•••2671') + ->and(SmsLedger::prune(0, 0))->toBe(['masked' => 0, 'deleted' => 0]); +}); + +test('sms ledger does nothing without a database', function () { + bind_test_container([]); + Facade::clearResolvedInstances(); + + expect(SmsLedger::available())->toBeFalse() + ->and(SmsLedger::record(sms_ledger_context(), 'reserved'))->toBeNull() + ->and(SmsLedger::countriesFor('company-1'))->toBe([]) + ->and(SmsLedger::sentCountFor('company-1'))->toBe(0); + + SmsLedger::update('some-uuid', ['status' => 'sent']); + expect(true)->toBeTrue(); +}); + +test('sms ledger stops writing for a minute after a failure and never throws', function () { + $capsule = SmsGuardFixture::database(); + $capsule->getConnection('mysql')->getSchemaBuilder()->drop('sms_sends'); + + expect(SmsLedger::available())->toBeTrue() + ->and(SmsLedger::record(sms_ledger_context(), 'reserved'))->toBeNull() + ->and(Cache::has(SmsLedger::UNAVAILABLE_KEY))->toBeTrue() + ->and(SmsLedger::available())->toBeFalse() + ->and(collect(app('log')->entries)->pluck(1)->all())->toContain('sms.ledger_unavailable'); + + SmsGuardFixture::smsSendsSchema($capsule); + + // Still marked unavailable, so nothing is written even though the table is back. + expect(SmsLedger::record(sms_ledger_context(), 'reserved'))->toBeNull() + ->and(SmsSend::count())->toBe(0); + + Cache::forget(SmsLedger::UNAVAILABLE_KEY); + SmsLedger::update('missing-row', ['status' => 'sent']); + + expect(SmsLedger::record(sms_ledger_context(), 'reserved'))->toBeString() + ->and(SmsSend::count())->toBe(1); +}); diff --git a/tests/Unit/Support/Sms/SmsPolicyTest.php b/tests/Unit/Support/Sms/SmsPolicyTest.php new file mode 100644 index 00000000..255dc72c --- /dev/null +++ b/tests/Unit/Support/Sms/SmsPolicyTest.php @@ -0,0 +1,255 @@ + true, + 'mode' => 'enforce', + 'alert_emails' => 'ops@example.test, OPS@example.test, not-an-email', + 'validation' => [ + 'enabled' => true, + 'allowed_types' => ['mobile', 'fixed_line_or_mobile'], + 'blocked_countries' => 'kp, ir', + 'blocked_prefixes' => '+881,+882', + ], + 'limits' => [ + 'destination' => ['cooldown_seconds' => 60, 'per_hour' => 3, 'per_day' => 5], + 'ip' => ['per_15_minutes' => 5, 'per_day' => 20], + 'company' => ['per_minute' => 10, 'per_hour' => 60, 'per_day' => 200], + 'tenant' => ['per_minute' => null, 'per_hour' => null, 'per_day' => null], + 'probation' => ['days' => 7, 'per_day' => 20], + 'platform' => [ + 'public' => ['per_minute' => 100, 'per_hour' => 1500, 'per_day' => 10000], + 'trusted' => ['per_minute' => 100, 'per_hour' => 1500, 'per_day' => 10000], + ], + ], + 'breaker' => [ + 'pause_minutes' => 60, + 'alert_percentages' => [50, 80], + 'distinct_destinations_per_10m' => 25, + 'auto_pauses_before_manual' => 2, + ], + 'purposes' => [ + '2fa' => ['trusted' => true, 'destination' => ['cooldown_seconds' => 20, 'per_hour' => 10]], + 'storefront_login' => ['destination' => ['cooldown_seconds' => 30]], + ], + ], $overrides); +} + +function sms_policy_database(array $guard = []): Capsule +{ + EloquentModel::clearBootedModels(); + session()->flush(); + + $connection = [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]; + + $container = bind_test_container([ + 'sms.guard' => sms_policy_config($guard), + 'database.default' => 'mysql', + 'database.connections.mysql' => $connection, + 'fleetbase.connection.db' => 'mysql', + ]); + $container->instance('cache', new CacheRepository(new ArrayStore())); + Facade::clearResolvedInstances(); + + $capsule = new Capsule($container); + $capsule->addConnection($connection, 'mysql'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + $capsule->getDatabaseManager()->setDefaultConnection('mysql'); + $container->instance('db', $capsule->getDatabaseManager()); + Facade::clearResolvedInstance('db'); + + $schema = $capsule->getConnection('mysql')->getSchemaBuilder(); + $schema->create('settings', function ($table) { + $table->increments('id'); + $table->string('key')->unique(); + $table->text('value')->nullable(); + }); + $schema->create('companies', function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable(); + $table->string('name')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + $schema->create('users', function ($table) { + $table->string('uuid')->primary(); + $table->string('company_uuid')->nullable(); + $table->string('type')->nullable(); + $table->string('name')->nullable(); + $table->string('email')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + $db = $capsule->getConnection('mysql'); + $db->table('companies')->insert([ + ['uuid' => 'company-old', 'public_id' => 'company_old', 'name' => 'Old Co', 'created_at' => Carbon::now()->subDays(30), 'updated_at' => Carbon::now()], + ['uuid' => 'company-new', 'public_id' => 'company_new', 'name' => 'New Co', 'created_at' => Carbon::now()->subDay(), 'updated_at' => Carbon::now()], + ]); + $db->table('users')->insert([ + ['uuid' => 'admin-1', 'type' => 'admin', 'name' => 'Root', 'email' => 'Root@example.test', 'deleted_at' => null], + ['uuid' => 'admin-2', 'type' => 'admin', 'name' => 'Deleted', 'email' => 'gone@example.test', 'deleted_at' => Carbon::now()], + ['uuid' => 'user-1', 'type' => 'user', 'name' => 'Member', 'email' => 'member@example.test', 'deleted_at' => null], + ]); + + return $capsule; +} + +afterEach(function () { + Carbon::setTestNow(); + session()->flush(); + EloquentModel::unsetConnectionResolver(); + EloquentModel::clearBootedModels(); + Facade::clearResolvedInstances(); +}); + +test('sms policy defaults are normalised from the configuration', function () { + bind_test_container(['sms.guard' => sms_policy_config(['mode' => 'Observe', 'breaker' => ['alert_percentages' => '50, 80, 100, 0']])]); + + $defaults = SmsPolicy::defaults(); + + expect($defaults['enabled'])->toBeTrue() + ->and($defaults['mode'])->toBe('enforce') + ->and($defaults['alert_emails'])->toBe(['ops@example.test']) + ->and($defaults['validation']['allowed_types'])->toBe(['MOBILE', 'FIXED_LINE_OR_MOBILE']) + ->and($defaults['validation']['blocked_countries'])->toBe(['KP', 'IR']) + ->and($defaults['validation']['blocked_prefixes'])->toBe(['+881', '+882']) + ->and($defaults['limits']['company'])->toBe(['per_minute' => 10, 'per_hour' => 60, 'per_day' => 200]) + ->and($defaults['limits']['tenant'])->toBe(['per_minute' => null, 'per_hour' => null, 'per_day' => null]) + ->and($defaults['breaker']['alert_percentages'])->toBe([50, 80]) + ->and($defaults['purposes']['2fa'])->toBe(['trusted' => true, 'destination' => ['cooldown_seconds' => 20, 'per_hour' => 10, 'per_day' => null]]) + ->and($defaults['overrides'])->toBe([]) + ->and($defaults['pauses'])->toBe([]); +}); + +test('sms policy falls back to the defaults when there is no database or cache', function () { + bind_test_container(['sms.guard' => sms_policy_config()]); + Facade::clearResolvedInstances(); + + expect(SmsPolicy::stored())->toBe([]) + ->and(SmsPolicy::settings()['mode'])->toBe('enforce') + ->and(SmsPolicy::isEnabled())->toBeTrue() + ->and(SmsPolicy::mode())->toBe('enforce') + ->and(SmsPolicy::isOnProbation('company-new', 7))->toBeFalse() + ->and(SmsPolicy::alertRecipients())->toBe(['ops@example.test']); +}); + +test('sms policy applies the stored administrator override and company exceptions', function () { + sms_policy_database(); + + Setting::configureSystem(SmsPolicy::SETTING_KEY, [ + 'mode' => 'monitor', + 'limits' => ['company' => ['per_day' => 50]], + 'overrides' => [ + ['company_uuid' => 'company-old', 'per_day' => 2000, 'note' => 'large client'], + ['company_uuid' => 'company-new', 'unlimited' => true], + ['company_uuid' => '', 'per_day' => 1], + ], + 'pauses' => ['company:company-old' => ['until' => null, 'reason' => 'manual', 'auto' => false]], + ]); + SmsPolicy::flush(); + + $settings = SmsPolicy::settings(); + $raised = SmsPolicy::limitsFor(new SmsContext('storefront_create_customer', '+447400100123', 'company-old', 'store', 'store-1', 'storefront', '203.0.113.9'), $settings); + $plain = SmsPolicy::limitsFor(new SmsContext('storefront_create_customer', '+447400100123', 'company-other'), $settings); + $noLimit = SmsPolicy::limitsFor(new SmsContext('storefront_create_customer', '+447400100123', 'company-new'), $settings); + + expect($settings['mode'])->toBe('monitor') + ->and($settings['limits']['company']['per_day'])->toBe(50) + ->and($settings['limits']['company']['per_hour'])->toBe(60) + ->and($settings['overrides'])->toHaveCount(2) + ->and($settings['pauses']['company:company-old']['reason'])->toBe('manual') + ->and(SmsPolicy::overrideFor('company-old', $settings)['note'])->toBe('large client') + ->and(SmsPolicy::overrideFor(null, $settings))->toBeNull() + ->and($raised['company']['per_day'])->toBe(2000) + ->and($raised['company']['per_hour'])->toBe(60) + ->and($raised['tenant'])->toBeNull() + ->and($raised['probation'])->toBeFalse() + ->and($plain['company']['per_day'])->toBe(50) + ->and($noLimit['company'])->toBeNull() + ->and($noLimit['company_unlimited'])->toBeTrue(); +}); + +test('sms policy update keeps the rest of the stored override and reset drops it', function () { + sms_policy_database(); + + SmsPolicy::update(['mode' => 'monitor']); + SmsPolicy::update(['limits' => ['company' => ['per_day' => 75]]]); + + $settings = SmsPolicy::settings(); + expect($settings['mode'])->toBe('monitor') + ->and($settings['limits']['company']['per_day'])->toBe(75) + ->and(SmsPolicy::stored())->toBe(['mode' => 'monitor', 'limits' => ['company' => ['per_day' => 75]]]); + + $reset = SmsPolicy::reset(); + expect($reset['mode'])->toBe('enforce') + ->and($reset['limits']['company']['per_day'])->toBe(200) + ->and(SmsPolicy::stored())->toBe([]); +}); + +test('sms policy puts young companies on probation and exempts trusted purposes from the company budget', function () { + sms_policy_database(); + + $young = SmsPolicy::limitsFor(new SmsContext('storefront_create_customer', '+447400100123', 'company-new', 'store', 'store-1', 'storefront', '203.0.113.9')); + $old = SmsPolicy::limitsFor(new SmsContext('storefront_create_customer', '+447400100123', 'company-old', null, null, 'api', '203.0.113.9')); + $trusted = SmsPolicy::limitsFor(new SmsContext('2fa', '+447400100123', 'company-new', 'store', 'store-1', 'console', '203.0.113.9')); + $login = SmsPolicy::limitsFor(new SmsContext('storefront_login', '+447400100123', 'company-old')); + + expect($young['probation'])->toBeTrue() + ->and($young['company']['per_day'])->toBe(20) + ->and($young['bucket'])->toBe('public') + ->and($young['ip'])->toBe(['per_15_minutes' => 5, 'per_day' => 20]) + ->and($old['probation'])->toBeFalse() + ->and($old['company']['per_day'])->toBe(200) + ->and($trusted['trusted'])->toBeTrue() + ->and($trusted['bucket'])->toBe('trusted') + ->and($trusted['company'])->toBeNull() + ->and($trusted['ip'])->toBeNull() + ->and($trusted['tenant'])->toBeNull() + ->and($trusted['destination'])->toBe(['cooldown_seconds' => 20, 'per_hour' => 10, 'per_day' => 5]) + ->and($login['destination']['cooldown_seconds'])->toBe(30) + ->and($login['destination']['per_hour'])->toBe(3) + ->and(SmsPolicy::isTrusted('2fa'))->toBeTrue() + ->and(SmsPolicy::isTrusted('storefront_login'))->toBeFalse() + ->and(SmsPolicy::isOnProbation(null, 7))->toBeFalse() + ->and(SmsPolicy::isOnProbation('company-new', 0))->toBeFalse() + ->and(SmsPolicy::isOnProbation('company-missing', 7))->toBeFalse(); +}); + +test('sms policy alerts go to the configured addresses and every live system administrator', function () { + sms_policy_database(); + + expect(SmsPolicy::alertRecipients())->toBe(['ops@example.test', 'root@example.test']) + ->and(SmsPolicy::adminUsers()->pluck('uuid')->all())->toBe(['admin-1']); +}); + +test('sms policy merge replaces lists and merges associative arrays', function () { + $merged = SmsPolicy::merge( + ['validation' => ['allowed_types' => ['MOBILE', 'FIXED_LINE_OR_MOBILE'], 'enabled' => true], 'mode' => 'enforce'], + ['validation' => ['allowed_types' => ['MOBILE']], 'mode' => 'monitor', 'extra' => ['a' => 1]] + ); + + expect($merged)->toBe([ + 'validation' => ['allowed_types' => ['MOBILE'], 'enabled' => true], + 'mode' => 'monitor', + 'extra' => ['a' => 1], + ]); +}); 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(); +}); diff --git a/tests/Unit/Traits/HasApiControllerBehaviorTest.php b/tests/Unit/Traits/HasApiControllerBehaviorTest.php index 8ef2feef..c2672e5a 100644 --- a/tests/Unit/Traits/HasApiControllerBehaviorTest.php +++ b/tests/Unit/Traits/HasApiControllerBehaviorTest.php @@ -107,7 +107,8 @@ public function qualifyColumn($column) public function isColumn($column): bool { - return $this->hasCompanyColumn && str_ends_with($column, 'company_uuid'); + // Mirrors Schema::hasColumn: only the bare column name matches, never "table.column". + return $this->hasCompanyColumn && $column === 'company_uuid'; } public function applyDirectivesToQuery(Request $request, $builder) diff --git a/tests/Unit/Traits/HasApiModelBehaviorTest.php b/tests/Unit/Traits/HasApiModelBehaviorTest.php index f3b4f071..4c624324 100644 --- a/tests/Unit/Traits/HasApiModelBehaviorTest.php +++ b/tests/Unit/Traits/HasApiModelBehaviorTest.php @@ -8,6 +8,7 @@ use Illuminate\Database\Capsule\Manager as Capsule; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Database\Eloquent\Model as EloquentModel; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Events\Dispatcher; use Illuminate\Http\JsonResponse; @@ -58,6 +59,11 @@ public function get(string $key, mixed $default = null): mixed return $this->store[$key] ?? $default; } + public function many(array $keys): array + { + return array_combine($keys, array_map(fn ($key) => $this->store[$key] ?? null, $keys)); + } + public function put(string $key, mixed $value, mixed $ttl = null): bool { $this->store[$key] = $value; @@ -172,7 +178,7 @@ class HasApiModelBehaviorRecord extends Model 'updated_at', ]; - public function childItems() + public function childItems(): HasMany { return $this->hasMany(HasApiModelBehaviorChild::class, 'record_uuid', 'uuid'); } @@ -348,7 +354,7 @@ class HasApiModelBehaviorSnakeRelationRecord extends Model 'name', ]; - public function child_items() + public function child_items(): HasMany { return $this->hasMany(HasApiModelBehaviorChild::class, 'record_uuid', 'uuid'); } @@ -363,6 +369,29 @@ class HasApiModelBehaviorChild extends Model public $timestamps = false; } +class HasApiModelBehaviorHiddenRecord extends HasApiModelBehaviorRecord +{ + protected $hidden = ['amount']; +} + +/** + * Mirrors fleetops' Order::dispatch(): a public method with no required arguments + * that writes a row and would fire events. A request naming it must never run it. + */ +class HasApiModelBehaviorSideEffectRecord extends HasApiModelBehaviorRecord +{ + public static int $dispatched = 0; + + public function dispatch(bool $save = true) + { + static::$dispatched++; + + $this->forceFill(['uuid' => 'junk-' . static::$dispatched, 'name' => 'Junk dispatch'])->saveQuietly(); + + return $this; + } +} + function has_api_model_behavior_database(): Capsule { EloquentModel::clearBootedModels(); @@ -836,6 +865,8 @@ public function total(): int expect($model->isColumn('company_uuid'))->toBeTrue() ->and($model->isColumn('missing_column'))->toBeFalse() + // isColumn takes a bare column name; a table-qualified name never matches. + ->and($model->isColumn($model->qualifyColumn('company_uuid')))->toBeFalse() ->and($model->shouldQualifyColumn('uuid'))->toBeTrue() ->and($model->shouldQualifyColumn('name'))->toBeFalse() ->and($model->isInvalidUpdateParam('name'))->toBeFalse() @@ -1039,12 +1070,67 @@ public function total(): int ->and($nullStatuses)->toBe(['record-3']) ->and($notNullStatuses)->toBe(['record-1', 'record-2']) ->and($likeNames)->toBe(['record-1']) - ->and(array_keys($relationshipBuilder->getEagerLoads()))->toBe(['childItems', 'childItems.grandChildren']) + ->and(array_keys($relationshipBuilder->getEagerLoads()))->toBe(['childItems']) ->and($relationshipBuilder->getQuery()->columns)->toBeNull() ->and(array_keys($snakeRelationBuilder->getEagerLoads()))->toBe(['child_items']) ->and($countBuilder->toSql())->toContain('api_model_behavior_children', 'child_items_count'); }); +test('api model behavior without adds to the hidden attributes instead of replacing them', function () { + $capsule = has_api_model_behavior_database(); + has_api_model_behavior_seed_records($capsule); + + $results = (new HasApiModelBehaviorHiddenRecord())->queryFromRequest(has_api_model_behavior_request([ + 'company_uuid' => 'company-a', + 'without' => ['slug'], + ])); + + expect($results->first()->getHidden())->toContain('amount', 'slug') + ->and($results->first()->toArray())->not->toHaveKeys(['amount', 'slug']) + ->and(HasApiModelBehaviorHiddenRecord::mutateModelWithRequest(has_api_model_behavior_request(['with' => 'childItems']), null))->toBeNull(); +}); + +test('api model behavior never calls a requested method that is not a relation', function (array $input) { + $capsule = has_api_model_behavior_database(); + has_api_model_behavior_seed_records($capsule); + session(['user' => 'session-user', 'company' => 'company-a']); + HasApiModelBehaviorSideEffectRecord::$dispatched = 0; + + $model = new HasApiModelBehaviorSideEffectRecord(); + $errors = []; + $attempt = function (string $path, Closure $call) use (&$errors) { + try { + return $call(); + } catch (Throwable $e) { + $errors[$path] = get_class($e); + } + }; + + $results = $attempt('list', fn () => $model->queryFromRequest(has_api_model_behavior_request(array_merge(['company_uuid' => 'company-a'], $input)))); + $found = $attempt('find', fn () => $model->getById('record_alpha', null, has_api_model_behavior_request($input))); + $created = $attempt('create', fn () => $model->createRecordFromRequest(has_api_model_behavior_request(array_merge([ + 'api_model_behavior_record' => ['uuid' => 'record-created', 'public_id' => 'record_created', 'name' => 'Created'], + ], $input), method: 'POST'))); + + $records = $capsule->getConnection('mysql')->table('api_model_behavior_records'); + + expect(HasApiModelBehaviorSideEffectRecord::$dispatched)->toBe(0) + ->and((clone $records)->where('name', 'like', 'Junk%')->orWhereNull('name')->count())->toBe(0) + ->and($errors)->toBe([]) + ->and($results)->toHaveCount(2) + ->and($found->uuid)->toBe('record-1') + ->and($created->uuid)->toBe('record-created'); +})->with([ + 'with array' => [['with' => ['dispatch']]], + 'with string' => [['with' => 'dispatch']], + 'expand' => [['expand' => ['dispatch']]], + 'nested' => [['with' => ['childItems', 'child_items.dispatch']]], + 'with_count' => [['with_count' => 'dispatch']], + 'with_count array' => [['with_count' => ['dispatch']]], + 'count' => [['count' => 'dispatch']], + 'base model method' => [['with' => ['saveInstance']]], +]); + test('api model behavior covers cached queries and create update response relation loading', function () { $capsule = has_api_model_behavior_database(); has_api_model_behavior_seed_records($capsule);