From 49fae0ae5e5c77e272730487f0ee41e72c33fa5e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:26:32 +0000 Subject: [PATCH 001/109] docs: plan configuration access audit Define the configuration access rules, current-only contracts, and nullable behavior that the framework audit will enforce. Record the retained-access inventory, package-by-package conversion decisions, regression coverage, documentation updates, and final verification workflow. --- ...config-access-and-legacy-fallback-audit.md | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 docs/plans/2026-08-14-2317-config-access-and-legacy-fallback-audit.md diff --git a/docs/plans/2026-08-14-2317-config-access-and-legacy-fallback-audit.md b/docs/plans/2026-08-14-2317-config-access-and-legacy-fallback-audit.md new file mode 100644 index 000000000..8cfdae16c --- /dev/null +++ b/docs/plans/2026-08-14-2317-config-access-and-legacy-fallback-audit.md @@ -0,0 +1,179 @@ +# Config Access and Legacy Fallback Audit + +## Goal + +Audit the `contrib/hypervel/components` repository so configuration reads state their real type, fail loudly when a required shipped key is missing or misspelled, preserve intentional null and mixed behavior, and remove Laravel compatibility fallbacks that Hypervel does not need. The finished code should use one default at the owning config file, with source-level fallbacks only for genuinely optional settings inside replaceable or dynamic config structures. + +## Ground Rules + +- Audit production PHP, route/config files, migrations, and Blade views. Distinguish configuration repositories from unrelated `get()` methods. +- Use `string()`, `integer()`, `float()`, `boolean()`, `array()`, or `collection()` when the consumed value has one required type. Use `collection()` when a required config array is immediately wrapped in a `Collection`. Helper-based code uses `config()->{type}()`; ported code keeps its helper/facade/injection structure. +- `Arr::get()` treats a present null key as present because `Arr::exists()` uses `array_key_exists()`. A call-site fallback for a key present in loaded config is therefore dead even when the configured value is null. +- Remove a call-site default only after confirming the exact key exists in the shipped framework or package config and that the owning config has loaded before the read. Package defaults merged in `register()` are unavailable to `isEnabled()`, and nested members of wholesale-replaced arrays may still need call-site fallbacks. A missing required key must raise the config repository's native `InvalidArgumentException`. +- Keep `get()` or an appropriate fallback when the read has meaningful null, union, mixed, dynamic, or deliberately optional nested behavior, or when it targets config owned by an optional package that may not be installed. Add or retain a functional test for meaningful null behavior. +- Named config groups in `LoadConfiguration::mergeableOptions()` merge entry by entry. Other nested arrays replace the default array. Retain a nested-member fallback only when both conditions hold: the application can replace the enclosing array wholesale, and omission of that member is supported behavior rather than broken configuration. For ported packages, compare each replaceable array with current upstream config: a Hypervel-only member added to an otherwise compatible upstream block must remain optional at the call site because an unmodified upstream config is valid. This does not apply when Hypervel intentionally rewrote the block into a different contract, such as `permission.cache`. Replaceability alone does not make required class names, cache keys, or other required members optional. +- Remove old Laravel key names, env names, and config shapes only when upstream history confirms that the branch exists for older applications rather than current functionality. Record public porting consequences concisely. +- Do not add wrappers, compatibility aliases, normalization layers, custom exceptions, or new configuration machinery. + +## Final Configuration Contract + +### Current-only keys and shapes + +1. Scheduling mutex cache configuration is `cache.schedule_store`, backed by `SCHEDULE_CACHE_STORE`. A null value selects the cache manager's default store. Adding this top-level key makes the old kernel fallback unreachable even when its value is null; remove the dead direct env chain so `SCHEDULE_CACHE_DRIVER` is unsupported by design. +2. `database.migrations` is the current array shape. `table` is required and consumers read `database.migrations.table` as a string. `update_date_on_publish` remains optional for application arrays that replace `migrations` wholesale, so `publishesMigrations()` retains its `false` fallback. The old scalar table-name shape is rejected instead of normalized at each caller. +3. `logging.deprecations` is the current array shape. Exception bootstrappers read it as an array; its nested `channel` remains nullable because null disables a deprecation logger. The old scalar channel shape is rejected. +4. Sentry logging uses `SENTRY_LOG_LEVEL`, falling back to the general `LOG_LEVEL`. Upstream deliberately renamed `SENTRY_LOGS_LEVEL` and retained it only as a backwards-compatibility alias; Hypervel removes that alias. +5. Socialite's `services.x` / `services.x-oauth-2` lookup remains. Both keys were introduced together for the single current X provider, and the undeprecated upstream alternate key is not a renamed-key shim or a separate driver. +6. Dynamic connection inheritance, generic package merge defaults, and current per-driver configuration such as `concurrency.driver.{name}` remain unchanged. + +Remove the migration-shape normalization from `DatabaseServiceProvider`, `DumpCommand`, and `DatabaseTruncation`; remove the deprecation-shape branches from the Foundation and Testbench `HandleExceptions` bootstrappers; remove direct env access from the Foundation console kernel; and remove the renamed Sentry env alias from its package config. + +### Missing shipped keys + +Add defaults at the owning config surface so consumers do not carry hidden defaults: + +- `cache.schedule_store => env('SCHEDULE_CACHE_STORE')` +- `fortify.limiters.verification => '6,1'` in both package config and publishable stub; retain the same fallback at its typed route read because an existing published `limiters` array replaces the package array wholesale +- `sanctum.routes => true` +- `sanctum.prefix => 'sanctum'` + +### Passkey config loading with nullable application settings + +`app.url` and `app.key` can be null for supported application behavior. PHP eagerly evaluates function arguments, so the current `env(..., parse_url(config('app.url'), ...))` expressions can fail even when explicit passkey env values are set, and the Fortify bridge's nested typed fallback reads `app.key` even when a dedicated passkey secret is configured. + +In the Passkeys config, Fortify config, and Fortify stub: + +```php +$appUrl = config('app.url'); +$defaultRelyingPartyId = $appUrl === null ? null : parse_url($appUrl, PHP_URL_HOST); +$defaultAllowedOrigins = $appUrl === null ? [] : [$appUrl]; +``` + +Use those derived defaults in the returned array. Keep `config('app.key')` as the default because a null app key is valid until a secret-requiring passkey operation runs. In `FortifyServiceProvider`, remove the local typed `app.url` read and its `parse_url($appUrl, ...)` / `[$appUrl]` bridge defaults; the Fortify config has already derived them safely. Copy nullable `fortify.passkeys.relying_party_id`, `allowed_origins`, and `user_handle_secret` with `get()` while reading timeout with `integer(..., 60000)` because the enclosing application array may replace the package defaults. In `Passkeys`, read those three nullable values with `get()` and let the existing domain guards reject null, wrong-type, empty ID/origin collections, and empty secrets with their purpose-built `RuntimeException` messages. Extend the secret guard to reject non-strings as well as the empty string. + +## Access Conversion + +Apply the following classifications to every matching production caller. Before editing each package, re-read its README, shipped config, relevant source, and relevant tests. + +| Area | Convert to typed access and remove duplicate defaults | Keep untyped because behavior is not one required type | +|---|---|---| +| Core app/foundation | required app environment, timezone, locale, name, debug flag, providers, aliases, faker locale, filesystem links, view paths/compiled path, database default, session path, and consumers that require a non-null app URL/key | package consumers of `app.url`, `app.asset_url`, `app.editor`, `app.key`, logging default, and session domain enumerated below | +| Auth, bus, queue, database | batching table, required failed-job settings, required defaults, email-verification expiry, and current migration fields | nullable batching database, dynamic password-broker expiry, optional connection queue/migration connection, dynamic auth provider/model keys, and database connection URL | +| Cache, hashing, rate limiter, signal | cache store collections/prefix and benchmark environment, hashing driver/options, rate-limiter settings, signal handlers | `cache.serializable_classes`, optional cache store members, and null schedule store | +| Reverb, server, gRPC | server collections, enabled flags, required route path and server settings | nullable gRPC compression and application/provider fields whose package contract allows null | +| Horizon | environment/default arrays, silenced arrays, prefix/path/use, memory and fast-termination settings, and layout name reads after boot normalization | nullable name during boot normalization, domain/watch, dynamic waits, environment fallback chain, optional queue names, and fallbacks inside replaced `trim` / `metrics` arrays | +| Inertia | page flags and arrays, history flag, SSR timeouts/backoff/enabled/runtime/URL/validation flags | nullable bundle and hot URL | +| JWT | algorithm, provider/storage classes, keys/parser/claims/validation settings, blacklist flags and numeric settings | nullable secret/TTL/refresh/issuer | +| Mail and notifications | required app name and top-level mail settings | optional `services.*` credentials and Markdown-member fallbacks inside the replaced `markdown` array | +| Passkeys and Fortify | required redirect, feature arrays, route view flag, middleware array, and configured non-null limiter strings | nullable relying-party ID/origins/secret, guard/domain/redirect/pipelines/limiter entries, passkey throttle, and timeout/verification fallbacks inside replaced arrays | +| Permission | top-level flags/resolver settings, migration arrays/booleans, required role/permission models, team foreign key, and cache settings | nullable team/default models, nullable pivot keys, and dynamic guard providers | +| Sanctum | cache enabled flag, token prefix, stateful domains, last-used flag, routes and prefix | nullable expiration/cache store/cache timings and nullable middleware entries; preserve class fallbacks for optional members of a replaced middleware array | +| Scout | after-commit/soft-delete flags, prefix, required chunk sizes, `meilisearch.host`, and required Typesense settings | nullable job options, optional Algolia timeouts/key/index settings, and fallbacks for Hypervel-only Meilisearch retry options inside the otherwise upstream-compatible replaced block | +| Sentry | pool/features/root options, log/channel/cache arrays, log level, and shipped tracing/breadcrumb flags | genuinely nullable SDK options and dynamic option-array reads | +| Telescope | enabled/defer flags, ignore/only arrays, watcher collection, middleware array, storage chunk, and required driver/connection values, including `Storage/EntryModel.php` | nullable path/domain/queue connection/queue/delay and polymorphic watcher definitions | +| Tinker | command/alias/dont-alias arrays | casters and `trust_project`, which accept their upstream union behavior | +| Testbench/testing | required app URL, providers, aliases, view paths, cache prefix, and database default | application/test overrides that deliberately allow absent app key, auth model, or connection URL; the timezone override method remains nullable for subclasses | + +Specific call-site defaults already confirmed to duplicate loaded config include Reverb `server.servers`; Sentry pool/features/root/log-channel/cache-store values; Horizon defaults and silenced arrays; Scout top-level flags/prefix; Permission top-level flags/team resolver; Hashing driver/options; Foundation providers/aliases/links; Tinker command/alias/dont-alias arrays; and the `app.name` fallbacks in `foundation/src/resources/health-up.blade.php:7` and `foundation/resources/exceptions/renderer/components/layout.blade.php:8`. Remove these dead defaults rather than preserving two apparent sources of truth. Retain the nested-member fallbacks listed below only where omission is supported behavior. + +### Authoritative retained-access inventory + +The following production reads deliberately remain untyped or retain a fallback. Line numbers refer to the pre-implementation tree; update this inventory if edits move or change a retained read. When a cited line contains multiple config reads, the Config surface column names only the retained read; every other read on that line follows the conversion table. + +| Location | Config surface | Reason | +|---|---|---| +| `src/auth/src/AuthManager.php:130,170`; `CreatesUserProviders.php:58,82`; `Passwords/PasswordBrokerManager.php:119` | `app.key`, dynamic guards/providers/brokers | null / dynamic | +| `src/broadcasting/src/BroadcastManager.php:462`; `src/cache/src/CacheManager.php:422`; `src/filesystem/src/FilesystemManager.php:701`; `src/log/src/LogManager.php:562`; `src/queue/src/QueueManager.php:353` | named driver configuration | dynamic; missing names feed manager-specific errors | +| `src/bus/src/BusServiceProvider.php:68`; `src/horizon/src/Http/Controllers/BatchesController.php:59` | `queue.batching.database` | null selects the default database connection | +| `src/cache/src/CacheManager.php:65` | `cache.serializable_classes` | `false|array|null|true` union | +| `src/cache/src/SwooleTableManager.php:109`; `src/rate-limiter/src/RateLimiter.php:142`; `src/rate-limiter/src/Swoole/TableManager.php:66` | named table/store config | dynamic plus package validation | +| `src/container/src/Attributes/Config.php:26`; `Attributes/Context.php:31`; `ContextualBindingBuilder.php:65`; `src/foundation/src/helpers.php:335` | caller-supplied keys/defaults | generic API | +| `src/console/src/GeneratorCommand.php:478-480`; `src/foundation/src/Console/PolicyMakeCommand.php:81-92` | dynamic auth provider/model lookup | dynamic / null | +| `src/database/src/Migrations/Migrator.php:617-625` | effective default and per-connection migration route | bootstrap / null / dynamic fallback | +| `src/encryption/src/Commands/KeyGenerateCommand.php:76,123`; `src/testbench/src/Foundation/Process/RemoteCommand.php:61`; `src/passkeys/config/passkeys.php:42`; `src/fortify/config/fortify.php:76`; `src/fortify/stubs/fortify.php:157` | `app.key` | null is the ungenerated-key state | +| `src/foundation/src/Bootstrap/HandleExceptions.php:123,135,145`; `src/testbench/src/Bootstrap/HandleExceptions.php:44` | dynamically created logging channels | bootstrap / dynamic | +| `src/foundation/src/Console/ConfigShowCommand.php:45`; `src/scout/src/EngineManager.php:264` | caller-supplied key/default | generic API | +| `src/foundation/src/Console/Kernel.php:285,293` | `app.schedule_timezone`, `cache.schedule_store` | null selects app timezone/default cache | +| `src/foundation/src/Concerns/ResolvesSourceHref.php:49`; `src/foundation/resources/exceptions/renderer/components/file-with-line.blade.php:13` | `app.editor` | null disables editor links | +| `src/routing/src/RoutingServiceProvider.php:61,91`; `src/inertia/src/Middleware.php:70-71` | `app.asset_url` | null disables the asset root/version input | +| `src/sanctum/src/Sanctum.php:54`; `src/wayfinder/src/Route.php:351`; `src/passkeys/config/passkeys.php:17,30`; `src/fortify/config/fortify.php:74-75`; `src/fortify/stubs/fortify.php:155-156` | `app.url` | null has explicit package behavior | +| `src/mail/resources/views/html/message.blade.php:4`; `src/mail/resources/views/text/message.blade.php:4` | `app.url` | null renders the mail header URL as empty | +| `src/foundation/src/Http/MaintenanceModeBypassCookie.php:22` | `session.domain` | null creates a host-only cookie | +| `src/grpc/src/GrpcServiceProvider.php:179` | `grpc.server.compression` | null disables compression | +| `src/horizon/src/HorizonServiceProvider.php:46,75` | `horizon.name` before normalization, `horizon.domain` | null/empty derives the application name or omits the domain | +| `src/horizon/src/Console/ListenCommand.php:36`; `HorizonCommand.php:37`; `MasterSupervisorController.php:28` | `horizon.watch`, `horizon.env` | absent optional override / dynamic fallback; the `app.env` reads on the latter two lines convert to typed access | +| `src/horizon/src/Listeners/MonitorWaitTimes.php:44-45` | `horizon.waits.{queue}` | dynamic; absent queue uses 60 seconds | +| `src/queue/src/Console/WorkCommand.php:363`; `ListenCommand.php:83`; `ClearCommand.php:74`; `src/horizon/src/Console/SupervisorCommand.php:144`; `ClearCommand.php:68`; `SupervisorOptions.php:78` | `queue.connections.{name}.queue` | retained fallback; queue is an optional member of a dynamic connection entry | +| `src/inertia/src/Commands/StartSsr.php:39`; `Ssr/BundleDetector.php:41`; `Ssr/HttpGateway.php:296` | SSR bundle/hot URL | null selects discovery or disables hot URL | +| `src/jwt/src/ClaimFactory.php:50`; `JwtManager.php:70,151,223`; `JwtServiceProvider.php:69,137` | secret, issuer, TTL, refresh TTL | null/union token behavior | +| `src/log/src/LogManager.php:570`; `src/foundation/src/Console/AboutCommand.php:176` | `logging.default` | nullable default channel | +| `src/mail/src/MailManager.php:314-332,662,674`; `src/notifications/src/Channels/SlackWebApiChannel.php:76,89,94` | mailer/address and `services.*` data | dynamic / optional credentials | +| `src/fortify/src/FortifyServiceProvider.php:126-129,185`; `AuthenticatedSessionController.php:56-57`; `src/fortify/routes/routes.php:30,47-50` | passkey bridge, domain, pipeline, guard, limiters | nullable bridge values / retained optional-member fallbacks / dynamic | +| `src/passkeys/src/Passkeys.php:50,91,291`; `src/passkeys/routes/routes.php:11,21` | relying party, origins, secret, guard, throttle | null is validated at use or disables middleware | +| `src/permission/src/PermissionRegistrar.php:242,260-261`; `src/permission/src/Guard.php:184` | team model, pivot keys, guard provider | null / dynamic fallback | +| `src/queue/src/Console/WorkCommand.php:330-333` | `queue.output_timezone` | null uses application timezone | +| `src/saloon/src/Console/Commands/MakeCommand.php:103`; `SaloonManager.php:460,562` | namespace and cache/limiter stores | null disables/selects defaults | +| `src/sanctum/src/Http/Middleware/EnsureFrontendRequestsAreStateful.php:54-58`; `SanctumServiceProvider.php:126-138,221`; `PersonalAccessToken.php:171,201,261,285,329`; `Console/Commands/PruneExpired.php:50` | middleware, expiration, cache store/timings | null removes middleware or selects supported package behavior; class fallbacks protect replaced middleware arrays | +| `src/scout/src/Traits/ConfiguresJobOptions.php:42-53`; `ScoutServiceProvider.php:110-116,156`; `Console/IndexCommand.php:67-68` | job options, Algolia timeouts, Meilisearch key, index settings | null / dynamic | +| `src/session/src/SessionManager.php:96,108,199,246` | connection, auth provider, block store | null / dynamic | +| `src/socialite/src/SocialiteManager.php:60-192` | provider credentials, including X alternate key | optional dynamic service config | +| `src/telescope/src/Telescope.php:194` | `horizon.path` | retained fallback; Horizon is an optional package and its config may not be loaded | +| `src/telescope/src/Telescope.php:197-198,625-628,823`; `TelescopeServiceProvider.php:68,70,203`; `Jobs/ProcessPendingUpdates.php:41,49,51`; `Http/Controllers/EntryController.php:63` | Telescope path/domain/queue/watcher | null / mixed / dynamic | +| `src/foundation/src/Testing/Concerns/InteractsWithParallelDatabase.php:58-106`; `InteractsWithRedis.php:242`; `src/testing/src/Concerns/TestDatabases.php:172-174`; `src/testbench/src/Bootstrap/LoadConfiguration.php:28,90-107`; `Concerns/HandlesDatabases.php:78-81`; `Foundation/Concerns/HandlesDatabaseConnections.php:50,75`; `Factories/UserFactory.php:59` | mutable test connection/model config | test harness / dynamic / null | +| `src/testbench/src/Concerns/CreatesApplication.php:128` | application timezone | nullable subclass extension point | +| `src/tinker/src/Console/TinkerCommand.php:54,150` | `trust_project`, casters | union / optional package config | +| `src/auth/src/Notifications/ResetPassword.php:97` | `auth.passwords.{broker}.expire` | retained fallback; the dynamic broker may have no expiry member | +| `src/support/src/ServiceProvider.php:325` | `database.migrations.update_date_on_publish` | retained fallback; intentionally optional member of a replaced array | +| `src/horizon/src/Repositories/RedisJobRepository.php:67-72`; `RedisMetricsRepository.php:231,256`; `src/horizon/src/Listeners/StoreTagsForFailedJob.php:32`; `TrimMonitoredJobs.php:30`; `TrimFailedJobs.php:30`; `src/horizon/src/Http/Controllers/DashboardStatsController.php:25`; `src/horizon/src/Console/SnapshotCommand.php:30` | `horizon.trim.*`, `horizon.metrics.*` | retained fallbacks; intentionally optional members of replaced arrays | +| `src/mail/src/MailServiceProvider.php:67-69`; `src/notifications/src/Channels/MailChannel.php:114` | `mail.markdown.*` | retained fallbacks; applications may provide a partial Markdown options array | +| `src/scout/src/ScoutServiceProvider.php:141-142` | `scout.meilisearch.retries`, `initial_retry_delay_ms` | retained fallbacks; Hypervel-only options in an otherwise upstream-compatible replaced block | +| `src/cache/src/CacheManager.php:410` | named store `prefix` | retained inheritance; an omitted per-store prefix uses `cache.prefix` | + +After conversion, use broad greps across `src/` to inspect every remaining direct `config('...')`, `Config::get()`, config-repository `get()`, cast around config access, typed getter with a fallback, repository ArrayAccess (`$config[...]` / `config()[...]`), and `has()`-then-`get()` pair. Each remaining occurrence must match this inventory or a documented generic repository implementation. Update the inventory when a final decision changes; do not silently add exceptions. + +## Regression Coverage + +Add tests at the public behavior boundary rather than tests that merely assert which repository method was called. + +1. **Scheduling:** in the Foundation console kernel tests, prove a null `app.schedule_timezone` makes scheduled events inherit `app.timezone`; a null `cache.schedule_store` leaves both `CacheEventMutex` and `CacheSchedulingMutex` on the default store; and a configured store selects it for both mutexes. Prove `SCHEDULE_CACHE_DRIVER` no longer affects configuration while `SCHEDULE_CACHE_STORE` does through the config-loading surface. +2. **Sessions:** extend `StartSessionTest` so a null `session.block_store` reaches `CacheFactory::store(null)` and the blocking request still uses the cache lock path. +3. **Mail:** render both HTML and text Markdown message views with `app.url = null`; prove the message body and application name still render and the header URL is empty rather than causing a typed-access failure. +4. **JWT:** extend `JwtManagerTest` with RS256 and the existing asymmetric-key fixtures while `jwt.secret` is null; encode and decode a token to prove null is valid for asymmetric signing. +5. **Passkeys:** extend route tests with `passkeys.throttle = null` and assert login and management routes omit throttle middleware. With `app.url = null`, prove explicit relying-party/origin env values load without eager fallback failure; without those env values, prove config still loads and `relyingPartyId()` / `allowedOrigins()` raise their domain-specific errors when used. With `app.key = null`, cover both an explicit passkey secret and the domain-specific missing-secret error at use. +6. **Fortify:** cover the same explicit and absent passkey env/secret cases through provider registration, proving nullable values can cross the Fortify-to-Passkeys bridge without breaking application boot and required values still fail when a WebAuthn operation requests them. Confirm the package config and published stub expose the verification limiter default while an existing replacement `limiters` array still receives the route fallback. +7. **Telescope:** extend HTTP route tests with `telescope.path = null` and prove dashboard/API routes register and respond without a prefix. +8. **Permission:** extend registrar tests so null role/permission pivot keys resolve to `role_id`/`permission_id`; extend assigned-model behavior so a null default model falls back to the authenticated guard model for raw IDs. +9. **Legacy shapes:** add focused tests proving the current custom migration-table array works and the old scalar `database.migrations` value fails; prove an array `logging.deprecations` with null channel works and the old scalar form fails. +10. **Sentry:** add env/config coverage proving `SENTRY_LOG_LEVEL` wins, `LOG_LEVEL` is its fallback, and `SENTRY_LOGS_LEVEL` is ignored. + +Existing functional coverage remains the guard for known nullable behavior, including `app.asset_url`, `app.key`, `app.editor`, `app.url`, `cache.serializable_classes`, `logging.default`, `session.connection`, Horizon name/domain, gRPC compression, Inertia bundle/hot URL, JWT TTL/refresh/issuer, Fortify guard/redirects, Permission nullable models/keys, Sanctum expiration/store/middleware, Saloon nullable stores, Scout job options, and queue output timezone. Do not add duplicate tests where that behavior is already directly asserted. + +Run every changed or new test file immediately from the repository root with `./vendor/bin/phpunit --no-progress `. Fix straightforward mistakes immediately; stop and investigate any behavioral contradiction or non-trivial defect before changing the contract. + +## Documentation + +1. Update `src/docs/configuration.md` with repository, facade, and `config()->{type}()` examples. Explain that typed getters are for non-null settings, `get()`/direct helper reads are for meaningful null or mixed values, and defaults belong in shipped config rather than repeated at callers. +2. Update `src/docs/scheduling.md` to document `cache.schedule_store` / `SCHEDULE_CACHE_STORE` and that null selects the default cache store. +3. Update `src/docs/sentry.md` so `SENTRY_LOG_LEVEL` falls back directly to `LOG_LEVEL`; describe `SENTRY_LOGS_LEVEL` as the removed backwards-compatibility alias for the upstream rename. +4. Add one concise, action-focused entry to the Configuration section of `src/docs/porting-from-laravel.md`: Hypervel requires `database.migrations` to be an array with a required `table` member (`update_date_on_publish` remains optional), requires array-form `logging.deprecations`, uses `cache.schedule_store` / `SCHEDULE_CACHE_STORE`, and does not support Laravel's old `SCHEDULE_CACHE_DRIVER`. +5. In the Providers section of the same guide, change the post-merge Courier singleton example to `$app->make('config')->array('courier')`. Explain that `isEnabled()` runs before the provider's own `mergeConfigFrom()` and therefore may read only already-loaded application/framework config or an intentional fallback. Use `config()->boolean('courier.enabled', false)` in that example so the optional unpublished flag is typed without throwing. + +Do not duplicate these docs in package READMEs. The removed Sentry env alias is package-specific and does not change a normal Laravel porting decision, so it belongs only in the Sentry documentation. + +## Implementation Order + +1. Add or correct shipped config definitions and published stubs. +2. Remove the four confirmed Laravel compatibility branches: schedule env alias, scalar migration config, scalar deprecation config, and renamed Sentry env alias. +3. Add and run the regression tests for those contract changes and for the eager passkey-default defects. +4. Convert production access package by package, one file at a time. Update an existing test file only after its production slice is coherent, then run that test file immediately. +5. Add the missing functional null tests and run each file immediately. +6. Make targeted documentation edits. +7. Re-run the residual-access audit and inspect the diff for accidental API, typing, config, or upstream-mergeability changes. + +## Final Verification and Review + +1. Run focused package suites for every package with changed production behavior. +2. Run `composer fix` once. It covers formatting, PHPStan, the parallel suite, and Testbench package-mode tests. +3. If a full check fails, use targeted checks to fix it, then run the failed step and every later step from the `fix` script. Repeat the whole command only if the correction warrants it. +4. Review every changed file and trace each config value from definition through merge semantics to consumer and test. Confirm null branches remain reachable, required settings fail loudly, deprecated aliases are absent, current public behavior is covered, and no duplicate fallback, stale comment, dead normalization, or workaround remains. +5. Re-run broad residual greps and `git diff --check`, then report the final result and notify the user. From a92c6e9b71abed174bdb7d642224383781091048 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:06:49 +0000 Subject: [PATCH 002/109] docs: adopt complete config schema plan Define shipped configuration as the authoritative schema for stable first-party settings and require fixed nested records to be complete when applications replace them. Record the typed-access conversions, legacy Laravel fallback removals, nullable behavior, regression coverage, documentation updates, and residual audit needed for implementation. Capture the handler-specific validation and Testbench rescue boundaries so missing configuration fails reliably without adding compatibility machinery. --- ...config-access-and-legacy-fallback-audit.md | 105 ++++++++++-------- 1 file changed, 60 insertions(+), 45 deletions(-) diff --git a/docs/plans/2026-08-14-2317-config-access-and-legacy-fallback-audit.md b/docs/plans/2026-08-14-2317-config-access-and-legacy-fallback-audit.md index 8cfdae16c..c5f7df513 100644 --- a/docs/plans/2026-08-14-2317-config-access-and-legacy-fallback-audit.md +++ b/docs/plans/2026-08-14-2317-config-access-and-legacy-fallback-audit.md @@ -2,40 +2,44 @@ ## Goal -Audit the `contrib/hypervel/components` repository so configuration reads state their real type, fail loudly when a required shipped key is missing or misspelled, preserve intentional null and mixed behavior, and remove Laravel compatibility fallbacks that Hypervel does not need. The finished code should use one default at the owning config file, with source-level fallbacks only for genuinely optional settings inside replaceable or dynamic config structures. +Audit the `contrib/hypervel/components` repository so configuration reads state their real type, every stable first-party setting is discoverable at its owning config file, required keys fail loudly when missing or misspelled, intentional null and mixed behavior remains supported, and Laravel compatibility fallbacks that Hypervel does not need are removed. The finished code should keep each literal default at the owning config file. Fixed nested arrays are complete schemas when replaced by an application; call-site defaults remain only when omission or inheritance is part of the supported feature contract, or when the owning optional package may not be installed. ## Ground Rules - Audit production PHP, route/config files, migrations, and Blade views. Distinguish configuration repositories from unrelated `get()` methods. +- Treat shipped config as the canonical schema for stable, statically named first-party settings so keys and types are discoverable to humans, tooling, and LLMs. Include advanced settings with concise comments explaining when to change them, their type, and any meaningful null behavior. If a value is not intended to be configurable, remove the config indirection and keep it as an internal invariant. Dynamic user-defined namespaces and open-ended third-party option bags expose their parent shape rather than pretending to enumerate every possible child. Generic caller-supplied keys and internal values created during bootstrap are not shipped settings. Do not add deprecated or backwards-compatibility-only keys to config; remove those reads instead. - Use `string()`, `integer()`, `float()`, `boolean()`, `array()`, or `collection()` when the consumed value has one required type. Use `collection()` when a required config array is immediately wrapped in a `Collection`. Helper-based code uses `config()->{type}()`; ported code keeps its helper/facade/injection structure. - `Arr::get()` treats a present null key as present because `Arr::exists()` uses `array_key_exists()`. A call-site fallback for a key present in loaded config is therefore dead even when the configured value is null. -- Remove a call-site default only after confirming the exact key exists in the shipped framework or package config and that the owning config has loaded before the read. Package defaults merged in `register()` are unavailable to `isEnabled()`, and nested members of wholesale-replaced arrays may still need call-site fallbacks. A missing required key must raise the config repository's native `InvalidArgumentException`. -- Keep `get()` or an appropriate fallback when the read has meaningful null, union, mixed, dynamic, or deliberately optional nested behavior, or when it targets config owned by an optional package that may not be installed. Add or retain a functional test for meaningful null behavior. -- Named config groups in `LoadConfiguration::mergeableOptions()` merge entry by entry. Other nested arrays replace the default array. Retain a nested-member fallback only when both conditions hold: the application can replace the enclosing array wholesale, and omission of that member is supported behavior rather than broken configuration. For ported packages, compare each replaceable array with current upstream config: a Hypervel-only member added to an otherwise compatible upstream block must remain optional at the call site because an unmodified upstream config is valid. This does not apply when Hypervel intentionally rewrote the block into a different contract, such as `permission.cache`. Replaceability alone does not make required class names, cache keys, or other required members optional. +- Remove a call-site default only after confirming the exact key exists in the shipped framework or package config and that the owning config has loaded before the read. Package defaults merged in `register()` are unavailable to `isEnabled()`. A missing required key must fail at its first read through the typed config `InvalidArgumentException`, the converted undefined-array-key `ErrorException` for an already-loaded fixed record, or a direct `InvalidArgumentException` presence guard inside the PHP error handler where warning conversion cannot run. Each failure names the missing key without a compatibility layer. +- Keep `get()` or an appropriate fallback when the read has meaningful null, union, mixed, dynamic, or deliberately optional behavior, or when it targets config owned by an optional package that may not be installed. Add or retain a functional test for meaningful null behavior. Do not classify a value as nullable or optional merely to preserve an omitted key from an old, partial, or upstream config file. +- Named config groups in `LoadConfiguration::mergeableOptions()` merge entry by entry, with an application entry replacing the complete framework entry of the same name. Other nested arrays replace the framework or package array wholesale. At every depth, an application that replaces a fixed array must provide its complete current schema. A member declared in the shipped fixed record is part of that schema; a supported member absent from the shipped record is optional. Consumers use typed getters without defaults for non-null members. For nullable members, enforce presence when omission could silently select lasting or security-sensitive behavior and mask a misspelled key; read the fixed block once and access the member directly so Hypervel's error handler converts an undefined-key warning to `ErrorException`. Do not add a presence guard when null's documented behavior is also the correct missing-key behavior, or when missing/null already reaches purpose-built downstream validation. PHP does not re-enter a userland error handler while it is already handling an error, so handler-context reads must instead use typed getters for non-null members and an explicit `array_key_exists()` guard for a nullable member whose presence requires enforcement. New required members are deliberate config-schema changes for applications that replace the enclosing array and must be documented for upgrades. Do not preserve partial old or upstream arrays through source defaults. Lists and fixed records must not be recursively merged because doing so prevents reliable replacement and clearing. - Remove old Laravel key names, env names, and config shapes only when upstream history confirms that the branch exists for older applications rather than current functionality. Record public porting consequences concisely. -- Do not add wrappers, compatibility aliases, normalization layers, custom exceptions, or new configuration machinery. +- Do not add wrappers, compatibility aliases, normalization layers, custom exception types, or new configuration machinery. A direct `InvalidArgumentException` presence guard is allowed only where a nullable required member cannot use a typed getter and PHP cannot convert an undefined-key warning because the read occurs inside the error handler. ## Final Configuration Contract ### Current-only keys and shapes 1. Scheduling mutex cache configuration is `cache.schedule_store`, backed by `SCHEDULE_CACHE_STORE`. A null value selects the cache manager's default store. Adding this top-level key makes the old kernel fallback unreachable even when its value is null; remove the dead direct env chain so `SCHEDULE_CACHE_DRIVER` is unsupported by design. -2. `database.migrations` is the current array shape. `table` is required and consumers read `database.migrations.table` as a string. `update_date_on_publish` remains optional for application arrays that replace `migrations` wholesale, so `publishesMigrations()` retains its `false` fallback. The old scalar table-name shape is rejected instead of normalized at each caller. -3. `logging.deprecations` is the current array shape. Exception bootstrappers read it as an array; its nested `channel` remains nullable because null disables a deprecation logger. The old scalar channel shape is rejected. +2. `database.migrations` is the current complete array shape. `table` and `update_date_on_publish` are required, and consumers read them as a string and boolean without defaults. The old scalar table-name shape is rejected instead of normalized at each caller. +3. `logging.deprecations` is the current complete array shape. The Foundation bootstrapper reads it as an array, requires nullable `channel` with `array_key_exists()`, and reads non-null `trace` with `boolean()` so missing members fail even while the PHP error handler is active. An explicitly null channel maps to the null logger. Testbench uses the same typed reads and no source defaults, but its intentional `rescue(..., report: false)` around parent deprecation reporting swallows configuration failures to protect the test harness; do not change that safety behavior. The old scalar channel shape is rejected. 4. Sentry logging uses `SENTRY_LOG_LEVEL`, falling back to the general `LOG_LEVEL`. Upstream deliberately renamed `SENTRY_LOGS_LEVEL` and retained it only as a backwards-compatibility alias; Hypervel removes that alias. 5. Socialite's `services.x` / `services.x-oauth-2` lookup remains. Both keys were introduced together for the single current X provider, and the undeprecated upstream alternate key is not a renamed-key shim or a separate driver. 6. Dynamic connection inheritance, generic package merge defaults, and current per-driver configuration such as `concurrency.driver.{name}` remain unchanged. -Remove the migration-shape normalization from `DatabaseServiceProvider`, `DumpCommand`, and `DatabaseTruncation`; remove the deprecation-shape branches from the Foundation and Testbench `HandleExceptions` bootstrappers; remove direct env access from the Foundation console kernel; and remove the renamed Sentry env alias from its package config. +Remove the migration-shape normalization from `DatabaseServiceProvider`, `DumpCommand`, and `DatabaseTruncation`; remove the deprecation-shape branches and Testbench's now-dead direct trace env fallback from the Foundation and Testbench `HandleExceptions` bootstrappers; remove direct env access from the Foundation console kernel; and remove the renamed Sentry env alias from its package config. ### Missing shipped keys Add defaults at the owning config surface so consumers do not carry hidden defaults: - `cache.schedule_store => env('SCHEDULE_CACHE_STORE')` -- `fortify.limiters.verification => '6,1'` in both package config and publishable stub; retain the same fallback at its typed route read because an existing published `limiters` array replaces the package array wholesale +- `fortify.limiters.verification => '6,1'` in both package config and publishable stub; read it as a required string without a route-level fallback +- `horizon.env => env('HORIZON_ENV')`; document that this advanced override selects a Horizon provisioning environment independently of `app.env`, while null inherits `app.env` and the command's `--environment` option takes precedence - `sanctum.routes => true` - `sanctum.prefix => 'sanctum'` +- `scout.algolia.connect_timeout`, `read_timeout`, and `write_timeout => null`; document that these advanced integer-second options override the Algolia SDK timeouts and null leaves its defaults unchanged +- `tinker.casters => []`; document the class-to-caster map used only for custom Tinker rendering ### Passkey config loading with nullable application settings @@ -49,7 +53,7 @@ $defaultRelyingPartyId = $appUrl === null ? null : parse_url($appUrl, PHP_URL_HO $defaultAllowedOrigins = $appUrl === null ? [] : [$appUrl]; ``` -Use those derived defaults in the returned array. Keep `config('app.key')` as the default because a null app key is valid until a secret-requiring passkey operation runs. In `FortifyServiceProvider`, remove the local typed `app.url` read and its `parse_url($appUrl, ...)` / `[$appUrl]` bridge defaults; the Fortify config has already derived them safely. Copy nullable `fortify.passkeys.relying_party_id`, `allowed_origins`, and `user_handle_secret` with `get()` while reading timeout with `integer(..., 60000)` because the enclosing application array may replace the package defaults. In `Passkeys`, read those three nullable values with `get()` and let the existing domain guards reject null, wrong-type, empty ID/origin collections, and empty secrets with their purpose-built `RuntimeException` messages. Extend the secret guard to reject non-strings as well as the empty string. +Use those derived defaults in the returned array. Keep `config('app.key')` as the default because a null app key is valid until a secret-requiring passkey operation runs. In `FortifyServiceProvider`, remove the local typed `app.url` read and its `parse_url($appUrl, ...)` / `[$appUrl]` bridge defaults; the Fortify config has already derived them safely. Copy nullable `fortify.passkeys.relying_party_id`, `allowed_origins`, and `user_handle_secret` with `get()` while reading the required timeout with `integer('fortify.passkeys.timeout')`. In `Passkeys`, read those three nullable values with `get()` and let the existing domain guards reject null, wrong-type, empty ID/origin collections, and empty secrets with their purpose-built `RuntimeException` messages. Extend the secret guard to reject non-strings as well as the empty string. ## Access Conversion @@ -58,23 +62,32 @@ Apply the following classifications to every matching production caller. Before | Area | Convert to typed access and remove duplicate defaults | Keep untyped because behavior is not one required type | |---|---|---| | Core app/foundation | required app environment, timezone, locale, name, debug flag, providers, aliases, faker locale, filesystem links, view paths/compiled path, database default, session path, and consumers that require a non-null app URL/key | package consumers of `app.url`, `app.asset_url`, `app.editor`, `app.key`, logging default, and session domain enumerated below | -| Auth, bus, queue, database | batching table, required failed-job settings, required defaults, email-verification expiry, and current migration fields | nullable batching database, dynamic password-broker expiry, optional connection queue/migration connection, dynamic auth provider/model keys, and database connection URL | +| Auth, bus, queue, database | batching table, required failed-job settings, required defaults, password-broker expiry/throttle, email-verification expiry, and current migration fields | nullable batching database, optional password-broker connection/store, optional connection queue/migration connection, dynamic auth provider/model keys, and database connection URL | | Cache, hashing, rate limiter, signal | cache store collections/prefix and benchmark environment, hashing driver/options, rate-limiter settings, signal handlers | `cache.serializable_classes`, optional cache store members, and null schedule store | | Reverb, server, gRPC | server collections, enabled flags, required route path and server settings | nullable gRPC compression and application/provider fields whose package contract allows null | -| Horizon | environment/default arrays, silenced arrays, prefix/path/use, memory and fast-termination settings, and layout name reads after boot normalization | nullable name during boot normalization, domain/watch, dynamic waits, environment fallback chain, optional queue names, and fallbacks inside replaced `trim` / `metrics` arrays | +| Horizon | environment/default arrays, silenced arrays, prefix/path/use, memory and fast-termination settings, complete `trim` / `metrics` members, and layout name reads after boot normalization | nullable name during boot normalization, domain/watch, dynamic waits, environment fallback chain, and optional queue names | | Inertia | page flags and arrays, history flag, SSR timeouts/backoff/enabled/runtime/URL/validation flags | nullable bundle and hot URL | | JWT | algorithm, provider/storage classes, keys/parser/claims/validation settings, blacklist flags and numeric settings | nullable secret/TTL/refresh/issuer | -| Mail and notifications | required app name and top-level mail settings | optional `services.*` credentials and Markdown-member fallbacks inside the replaced `markdown` array | -| Passkeys and Fortify | required redirect, feature arrays, route view flag, middleware array, and configured non-null limiter strings | nullable relying-party ID/origins/secret, guard/domain/redirect/pipelines/limiter entries, passkey throttle, and timeout/verification fallbacks inside replaced arrays | +| Mail and notifications | required app name, top-level mail settings, and complete Markdown settings | optional `services.*` credentials | +| Passkeys and Fortify | required redirect, feature arrays, route view flag, middleware array, passkey timeout, and configured non-null limiter strings | nullable relying-party ID/origins/secret, guard/domain/redirect/pipelines/limiter entries, and passkey throttle | | Permission | top-level flags/resolver settings, migration arrays/booleans, required role/permission models, team foreign key, and cache settings | nullable team/default models, nullable pivot keys, and dynamic guard providers | -| Sanctum | cache enabled flag, token prefix, stateful domains, last-used flag, routes and prefix | nullable expiration/cache store/cache timings and nullable middleware entries; preserve class fallbacks for optional members of a replaced middleware array | -| Scout | after-commit/soft-delete flags, prefix, required chunk sizes, `meilisearch.host`, and required Typesense settings | nullable job options, optional Algolia timeouts/key/index settings, and fallbacks for Hypervel-only Meilisearch retry options inside the otherwise upstream-compatible replaced block | +| Sanctum | cache enabled flag, token prefix, stateful domains, last-used flag, routes and prefix | nullable expiration/cache store and middleware entries; cache TTL/update interval retain their existing range validation, with no duplicate defaults | +| Scout | after-commit/soft-delete flags, prefix, required chunk sizes, `meilisearch.host`, required Meilisearch retry settings, and required Typesense settings | nullable job options and nullable Algolia timeout overrides/key/index settings | | Sentry | pool/features/root options, log/channel/cache arrays, log level, and shipped tracing/breadcrumb flags | genuinely nullable SDK options and dynamic option-array reads | | Telescope | enabled/defer flags, ignore/only arrays, watcher collection, middleware array, storage chunk, and required driver/connection values, including `Storage/EntryModel.php` | nullable path/domain/queue connection/queue/delay and polymorphic watcher definitions | -| Tinker | command/alias/dont-alias arrays | casters and `trust_project`, which accept their upstream union behavior | +| Tinker | command/alias/dont-alias/casters arrays | `trust_project`, which accepts its upstream union behavior | | Testbench/testing | required app URL, providers, aliases, view paths, cache prefix, and database default | application/test overrides that deliberately allow absent app key, auth model, or connection URL; the timezone override method remains nullable for subclasses | -Specific call-site defaults already confirmed to duplicate loaded config include Reverb `server.servers`; Sentry pool/features/root/log-channel/cache-store values; Horizon defaults and silenced arrays; Scout top-level flags/prefix; Permission top-level flags/team resolver; Hashing driver/options; Foundation providers/aliases/links; Tinker command/alias/dont-alias arrays; and the `app.name` fallbacks in `foundation/src/resources/health-up.blade.php:7` and `foundation/resources/exceptions/renderer/components/layout.blade.php:8`. Remove these dead defaults rather than preserving two apparent sources of truth. Retain the nested-member fallbacks listed below only where omission is supported behavior. +Specific call-site defaults already confirmed to duplicate loaded config include Reverb `server.servers`; Sentry pool/features/root/log-channel/cache-store values; Horizon defaults, silenced arrays, trim periods, and metric retention; Scout top-level flags/prefix and Meilisearch retry settings; Permission top-level flags/team resolver; Hashing driver/options; Foundation providers/aliases/links and migration publishing flag; Foundation and Testbench deprecation channel/trace handling; Fortify verification limiter and passkey timeout; Sanctum middleware classes; Mail Markdown settings; Tinker command/alias/dont-alias/casters arrays; password-broker expiry/throttle; and the `app.name` fallbacks in `foundation/src/resources/health-up.blade.php:7` and `foundation/resources/exceptions/renderer/components/layout.blade.php:8`. Remove these dead defaults rather than preserving two apparent sources of truth. Retain a source fallback only where omission or inheritance is supported behavior independently of merge mechanics. + +Apply the complete-schema rule explicitly to the previously partial fixed blocks: + +- Read every `horizon.trim.*` and `horizon.metrics.*` value as an integer without a fallback, including `recent_failed`; do not derive one configured retention period from another. +- Read `mail.markdown.theme`, `paths`, and `extensions` at `MailServiceProvider`, `MailChannel`, and `Mailable`; `fortify.passkeys.timeout`; `fortify.limiters.verification`; `database.migrations.update_date_on_publish`; and the two Scout Meilisearch retry settings with their typed getters and no defaults. +- In `PasswordBrokerManager`, access `expire` and `throttle` directly on the broker record instead of using `60` / `0`; this preserves the upstream method boundary and makes a missing member raise the converted undefined-array-key `ErrorException`. Read the notification expiry through the dynamic typed config key without a fallback. Connection and cache-store omission retain their existing default-selection semantics because those members are absent from the shipped record. +- Read `logging.deprecations` as an array in both exception bootstrappers. In Foundation, require `channel` with `array_key_exists()` and a direct `InvalidArgumentException` naming `logging.deprecations.channel`, then map an explicitly null channel to the null logger. Read `trace` through `boolean('logging.deprecations.trace')`, including Foundation's later deprecation-reporting path. This explicit validation is required because PHP will not invoke the userland error handler recursively for an undefined-key warning raised while that handler is already active. Remove Testbench's separate env read and `true` fallback; the complete shipped block's configured boolean is the only trace value. Preserve Testbench's existing rescue boundary: its typed reads remove duplicate defaults but deliberately do not create a missing-member enforcement guarantee or add a guard that rescue would always swallow. +- In Sanctum, read the complete `sanctum.middleware` block once and access all three members directly before filtering. Explicit null still removes middleware, while a missing member raises the converted undefined-array-key `ErrorException` instead of silently removing cookie encryption, CSRF validation, or session authentication. The shipped class values remain the only defaults. +- Do not add missing-versus-null checks for the Fortify passkey trio or Scout Algolia timeouts. The passkey values already reach purpose-built errors at first use, while a null Algolia timeout is documented to leave the SDK default unchanged and missing has the same correct result. By contrast, enforce `logging.deprecations.channel` because treating a misspelled key as null would silently discard deprecation logging with no later signal. ### Authoritative retained-access inventory @@ -92,6 +105,7 @@ The following production reads deliberately remain untyped or retain a fallback. | `src/database/src/Migrations/Migrator.php:617-625` | effective default and per-connection migration route | bootstrap / null / dynamic fallback | | `src/encryption/src/Commands/KeyGenerateCommand.php:76,123`; `src/testbench/src/Foundation/Process/RemoteCommand.php:61`; `src/passkeys/config/passkeys.php:42`; `src/fortify/config/fortify.php:76`; `src/fortify/stubs/fortify.php:157` | `app.key` | null is the ungenerated-key state | | `src/foundation/src/Bootstrap/HandleExceptions.php:123,135,145`; `src/testbench/src/Bootstrap/HandleExceptions.php:44` | dynamically created logging channels | bootstrap / dynamic | +| `src/foundation/src/Bootstrap/HandleExceptions.php:89-92,129-130`; `src/testbench/src/Bootstrap/HandleExceptions.php:49-54` | `logging.deprecations` members | fixed handler-context block; Foundation uses typed reads and an explicit nullable-channel guard because warnings cannot re-enter the active handler, while Testbench retains the reads but intentionally rescues reporting failures | | `src/foundation/src/Console/ConfigShowCommand.php:45`; `src/scout/src/EngineManager.php:264` | caller-supplied key/default | generic API | | `src/foundation/src/Console/Kernel.php:285,293` | `app.schedule_timezone`, `cache.schedule_store` | null selects app timezone/default cache | | `src/foundation/src/Concerns/ResolvesSourceHref.php:49`; `src/foundation/resources/exceptions/renderer/components/file-with-line.blade.php:13` | `app.editor` | null disables editor links | @@ -101,50 +115,50 @@ The following production reads deliberately remain untyped or retain a fallback. | `src/foundation/src/Http/MaintenanceModeBypassCookie.php:22` | `session.domain` | null creates a host-only cookie | | `src/grpc/src/GrpcServiceProvider.php:179` | `grpc.server.compression` | null disables compression | | `src/horizon/src/HorizonServiceProvider.php:46,75` | `horizon.name` before normalization, `horizon.domain` | null/empty derives the application name or omits the domain | -| `src/horizon/src/Console/ListenCommand.php:36`; `HorizonCommand.php:37`; `MasterSupervisorController.php:28` | `horizon.watch`, `horizon.env` | absent optional override / dynamic fallback; the `app.env` reads on the latter two lines convert to typed access | +| `src/horizon/src/Console/ListenCommand.php:36`; `HorizonCommand.php:37`; `MasterSupervisorController.php:28` | `horizon.watch`, `horizon.env` | null or empty watch paths inherit the application watcher config; null environment inherits `app.env`; the `app.env` reads on the latter two lines convert to typed access | | `src/horizon/src/Listeners/MonitorWaitTimes.php:44-45` | `horizon.waits.{queue}` | dynamic; absent queue uses 60 seconds | -| `src/queue/src/Console/WorkCommand.php:363`; `ListenCommand.php:83`; `ClearCommand.php:74`; `src/horizon/src/Console/SupervisorCommand.php:144`; `ClearCommand.php:68`; `SupervisorOptions.php:78` | `queue.connections.{name}.queue` | retained fallback; queue is an optional member of a dynamic connection entry | +| `src/queue/src/Console/WorkCommand.php:363`; `ListenCommand.php:83`; `ClearCommand.php:74`; `src/horizon/src/Console/SupervisorCommand.php:144`; `ClearCommand.php:68`; `SupervisorOptions.php:78` | `queue.connections.{name}.queue` | retained fallback; callers span drivers with different fixed schemas, and `sync`, `background`, and `deferred` do not declare a queue member | | `src/inertia/src/Commands/StartSsr.php:39`; `Ssr/BundleDetector.php:41`; `Ssr/HttpGateway.php:296` | SSR bundle/hot URL | null selects discovery or disables hot URL | | `src/jwt/src/ClaimFactory.php:50`; `JwtManager.php:70,151,223`; `JwtServiceProvider.php:69,137` | secret, issuer, TTL, refresh TTL | null/union token behavior | | `src/log/src/LogManager.php:570`; `src/foundation/src/Console/AboutCommand.php:176` | `logging.default` | nullable default channel | | `src/mail/src/MailManager.php:314-332,662,674`; `src/notifications/src/Channels/SlackWebApiChannel.php:76,89,94` | mailer/address and `services.*` data | dynamic / optional credentials | -| `src/fortify/src/FortifyServiceProvider.php:126-129,185`; `AuthenticatedSessionController.php:56-57`; `src/fortify/routes/routes.php:30,47-50` | passkey bridge, domain, pipeline, guard, limiters | nullable bridge values / retained optional-member fallbacks / dynamic | +| `src/fortify/src/FortifyServiceProvider.php:126-129,185`; `AuthenticatedSessionController.php:56-57`; `src/fortify/routes/routes.php:30,47-49` | passkey bridge, domain, pipeline, guard, nullable limiters | nullable / dynamic; the passkey timeout on these lines converts to typed access without a default | | `src/passkeys/src/Passkeys.php:50,91,291`; `src/passkeys/routes/routes.php:11,21` | relying party, origins, secret, guard, throttle | null is validated at use or disables middleware | | `src/permission/src/PermissionRegistrar.php:242,260-261`; `src/permission/src/Guard.php:184` | team model, pivot keys, guard provider | null / dynamic fallback | | `src/queue/src/Console/WorkCommand.php:330-333` | `queue.output_timezone` | null uses application timezone | | `src/saloon/src/Console/Commands/MakeCommand.php:103`; `SaloonManager.php:460,562` | namespace and cache/limiter stores | null disables/selects defaults | -| `src/sanctum/src/Http/Middleware/EnsureFrontendRequestsAreStateful.php:54-58`; `SanctumServiceProvider.php:126-138,221`; `PersonalAccessToken.php:171,201,261,285,329`; `Console/Commands/PruneExpired.php:50` | middleware, expiration, cache store/timings | null removes middleware or selects supported package behavior; class fallbacks protect replaced middleware arrays | -| `src/scout/src/Traits/ConfiguresJobOptions.php:42-53`; `ScoutServiceProvider.php:110-116,156`; `Console/IndexCommand.php:67-68` | job options, Algolia timeouts, Meilisearch key, index settings | null / dynamic | +| `src/sanctum/src/Http/Middleware/EnsureFrontendRequestsAreStateful.php:54-58`; `SanctumServiceProvider.php:126-138,221`; `PersonalAccessToken.php:171,201,261,285,329`; `Console/Commands/PruneExpired.php:50` | middleware, expiration, cache store/timings | null removes middleware or selects supported package behavior; non-null middleware class defaults are removed, while cache timings retain package validation | +| `src/scout/src/Traits/ConfiguresJobOptions.php:42-53`; `ScoutServiceProvider.php:110-116,156`; `Console/IndexCommand.php:67-68` | job options, Algolia timeouts, Meilisearch key, index settings | null delegates to the queue worker or SDK / dynamic | | `src/session/src/SessionManager.php:96,108,199,246` | connection, auth provider, block store | null / dynamic | | `src/socialite/src/SocialiteManager.php:60-192` | provider credentials, including X alternate key | optional dynamic service config | | `src/telescope/src/Telescope.php:194` | `horizon.path` | retained fallback; Horizon is an optional package and its config may not be loaded | | `src/telescope/src/Telescope.php:197-198,625-628,823`; `TelescopeServiceProvider.php:68,70,203`; `Jobs/ProcessPendingUpdates.php:41,49,51`; `Http/Controllers/EntryController.php:63` | Telescope path/domain/queue/watcher | null / mixed / dynamic | | `src/foundation/src/Testing/Concerns/InteractsWithParallelDatabase.php:58-106`; `InteractsWithRedis.php:242`; `src/testing/src/Concerns/TestDatabases.php:172-174`; `src/testbench/src/Bootstrap/LoadConfiguration.php:28,90-107`; `Concerns/HandlesDatabases.php:78-81`; `Foundation/Concerns/HandlesDatabaseConnections.php:50,75`; `Factories/UserFactory.php:59` | mutable test connection/model config | test harness / dynamic / null | | `src/testbench/src/Concerns/CreatesApplication.php:128` | application timezone | nullable subclass extension point | -| `src/tinker/src/Console/TinkerCommand.php:54,150` | `trust_project`, casters | union / optional package config | -| `src/auth/src/Notifications/ResetPassword.php:97` | `auth.passwords.{broker}.expire` | retained fallback; the dynamic broker may have no expiry member | -| `src/support/src/ServiceProvider.php:325` | `database.migrations.update_date_on_publish` | retained fallback; intentionally optional member of a replaced array | -| `src/horizon/src/Repositories/RedisJobRepository.php:67-72`; `RedisMetricsRepository.php:231,256`; `src/horizon/src/Listeners/StoreTagsForFailedJob.php:32`; `TrimMonitoredJobs.php:30`; `TrimFailedJobs.php:30`; `src/horizon/src/Http/Controllers/DashboardStatsController.php:25`; `src/horizon/src/Console/SnapshotCommand.php:30` | `horizon.trim.*`, `horizon.metrics.*` | retained fallbacks; intentionally optional members of replaced arrays | -| `src/mail/src/MailServiceProvider.php:67-69`; `src/notifications/src/Channels/MailChannel.php:114` | `mail.markdown.*` | retained fallbacks; applications may provide a partial Markdown options array | -| `src/scout/src/ScoutServiceProvider.php:141-142` | `scout.meilisearch.retries`, `initial_retry_delay_ms` | retained fallbacks; Hypervel-only options in an otherwise upstream-compatible replaced block | +| `src/tinker/src/Console/TinkerCommand.php:54` | `trust_project` | upstream union behavior | | `src/cache/src/CacheManager.php:410` | named store `prefix` | retained inheritance; an omitted per-store prefix uses `cache.prefix` | -After conversion, use broad greps across `src/` to inspect every remaining direct `config('...')`, `Config::get()`, config-repository `get()`, cast around config access, typed getter with a fallback, repository ArrayAccess (`$config[...]` / `config()[...]`), and `has()`-then-`get()` pair. Each remaining occurrence must match this inventory or a documented generic repository implementation. Update the inventory when a final decision changes; do not silently add exceptions. +After conversion, use broad greps across `src/` to inspect every remaining direct `config('...')`, `Config::get()`, config-repository `get()`, cast around config access, typed getter with a fallback, repository ArrayAccess (`$config[...]` / `config()[...]`), `has()`-then-`get()` pair, and array-level `??` / `isset()` fallback applied after loading config. Cross-check every statically named first-party read against its owning shipped config. A read may lack a shipped declaration only when it addresses a dynamic namespace, an open-ended third-party option bag, a generic caller-supplied key, an internal value created during bootstrap, or config owned by an optional package that may not be installed. Every remaining source literal must implement documented omission, inheritance, or null behavior independently of shallow merge mechanics; an old, partial, or upstream config shape is not sufficient justification. Each remaining occurrence must match this inventory or a documented generic repository implementation. Update the inventory when a final decision changes; do not silently add exceptions. ## Regression Coverage Add tests at the public behavior boundary rather than tests that merely assert which repository method was called. 1. **Scheduling:** in the Foundation console kernel tests, prove a null `app.schedule_timezone` makes scheduled events inherit `app.timezone`; a null `cache.schedule_store` leaves both `CacheEventMutex` and `CacheSchedulingMutex` on the default store; and a configured store selects it for both mutexes. Prove `SCHEDULE_CACHE_DRIVER` no longer affects configuration while `SCHEDULE_CACHE_STORE` does through the config-loading surface. -2. **Sessions:** extend `StartSessionTest` so a null `session.block_store` reaches `CacheFactory::store(null)` and the blocking request still uses the cache lock path. -3. **Mail:** render both HTML and text Markdown message views with `app.url = null`; prove the message body and application name still render and the header URL is empty rather than causing a typed-access failure. -4. **JWT:** extend `JwtManagerTest` with RS256 and the existing asymmetric-key fixtures while `jwt.secret` is null; encode and decode a token to prove null is valid for asymmetric signing. -5. **Passkeys:** extend route tests with `passkeys.throttle = null` and assert login and management routes omit throttle middleware. With `app.url = null`, prove explicit relying-party/origin env values load without eager fallback failure; without those env values, prove config still loads and `relyingPartyId()` / `allowedOrigins()` raise their domain-specific errors when used. With `app.key = null`, cover both an explicit passkey secret and the domain-specific missing-secret error at use. -6. **Fortify:** cover the same explicit and absent passkey env/secret cases through provider registration, proving nullable values can cross the Fortify-to-Passkeys bridge without breaking application boot and required values still fail when a WebAuthn operation requests them. Confirm the package config and published stub expose the verification limiter default while an existing replacement `limiters` array still receives the route fallback. -7. **Telescope:** extend HTTP route tests with `telescope.path = null` and prove dashboard/API routes register and respond without a prefix. -8. **Permission:** extend registrar tests so null role/permission pivot keys resolve to `role_id`/`permission_id`; extend assigned-model behavior so a null default model falls back to the authenticated guard model for raw IDs. -9. **Legacy shapes:** add focused tests proving the current custom migration-table array works and the old scalar `database.migrations` value fails; prove an array `logging.deprecations` with null channel works and the old scalar form fails. -10. **Sentry:** add env/config coverage proving `SENTRY_LOG_LEVEL` wins, `LOG_LEVEL` is its fallback, and `SENTRY_LOGS_LEVEL` is ignored. +2. **Horizon:** add the shipped nullable `horizon.env` default to config coverage. Prove the Horizon command and inactive-master records use `app.env` when it is null and the configured Horizon environment when non-null; retain command-option precedence over both. Existing `horizon.watch = null` and `[]` coverage must continue proving that the listen command inherits the application watcher paths. Prove representative missing `trim` and nested `metrics` members raise the native typed-config exception with the complete key. +3. **Sessions:** extend `StartSessionTest` so a null `session.block_store` reaches `CacheFactory::store(null)` and the blocking request still uses the cache lock path. +4. **Mail:** render both HTML and text Markdown message views with `app.url = null`; prove the message body and application name still render and the header URL is empty rather than causing a typed-access failure. Resolve Markdown rendering with a required member removed from the replaced `mail.markdown` block and prove the native typed-config exception identifies it. +5. **JWT:** extend `JwtManagerTest` with RS256 and the existing asymmetric-key fixtures while `jwt.secret` is null; encode and decode a token to prove null is valid for asymmetric signing. +6. **Passkeys:** extend route tests with `passkeys.throttle = null` and assert login and management routes omit throttle middleware. With `app.url = null`, prove explicit relying-party/origin env values load without eager fallback failure; without those env values, prove config still loads and `relyingPartyId()` / `allowedOrigins()` raise their domain-specific errors when used. With `app.key = null`, cover both an explicit passkey secret and the domain-specific missing-secret error at use. +7. **Fortify:** cover the same explicit and absent passkey env/secret cases through provider registration, proving nullable values can cross the Fortify-to-Passkeys bridge without breaking application boot and required values still fail when a WebAuthn operation requests them. Confirm the package config and published stub expose the verification limiter and passkey timeout defaults. Prove replacement `limiters` / `passkeys` arrays missing those required members fail with the native typed-config exception instead of receiving source defaults. +8. **Telescope:** extend HTTP route tests with `telescope.path = null` and prove dashboard/API routes register and respond without a prefix. +9. **Permission:** extend registrar tests so null role/permission pivot keys resolve to `role_id`/`permission_id`; extend assigned-model behavior so a null default model falls back to the authenticated guard model for raw IDs. +10. **Sanctum:** retain the existing null `authenticate_session` behavior and add functional coverage proving explicit null `encrypt_cookies` and `validate_csrf_token` entries remove those middleware without source class fallbacks. Prove a replaced middleware block missing any declared member raises the converted undefined-array-key `ErrorException` instead of silently dropping middleware. +11. **Scout:** extend the config-file test to require all three Algolia timeout keys with null defaults. Extend the service-provider test to prove null preserves the Algolia SDK defaults and configured integers override each timeout. Prove a replaced Meilisearch block missing either required retry setting fails instead of receiving a source default. +12. **Tinker:** require the shipped empty caster map and prove a configured custom caster is applied during Tinker output. +13. **Fixed nested schemas:** extend the nearest password-broker and migration-publishing tests to prove missing expiry/throttle raise the converted undefined-array-key `ErrorException` and a missing `database.migrations.update_date_on_publish` member raises the typed-config `InvalidArgumentException`, rather than either path receiving a source default. Do not add redundant missing-key cases where the Horizon, Mail, Fortify, and Scout coverage above already exercises the same rule. +14. **Legacy shapes:** add focused tests proving the current custom migration-table array works and the old scalar `database.migrations` value fails. In the established Foundation `HandleExceptionsTest` pattern, mock `runningUnitTests()` as false and `hasBeenBootstrapped()` as true so neither early return bypasses the behavior, then call `handleDeprecationError()` directly. Prove a complete `logging.deprecations` array with a null channel uses the null logger, the old scalar form fails, and missing `channel` or `trace` members raise `InvalidArgumentException` naming the missing key instead of receiving source defaults. Keep Testbench's existing real-deprecation coverage as the guard for its rescued harness behavior; do not add a missing-member failure assertion that its deliberate rescue cannot provide. +15. **Sentry:** add env/config coverage proving `SENTRY_LOG_LEVEL` wins, `LOG_LEVEL` is its fallback, and `SENTRY_LOGS_LEVEL` is ignored. Existing functional coverage remains the guard for known nullable behavior, including `app.asset_url`, `app.key`, `app.editor`, `app.url`, `cache.serializable_classes`, `logging.default`, `session.connection`, Horizon name/domain, gRPC compression, Inertia bundle/hot URL, JWT TTL/refresh/issuer, Fortify guard/redirects, Permission nullable models/keys, Sanctum expiration/store/middleware, Saloon nullable stores, Scout job options, and queue output timezone. Do not add duplicate tests where that behavior is already directly asserted. @@ -152,11 +166,12 @@ Run every changed or new test file immediately from the repository root with `./ ## Documentation -1. Update `src/docs/configuration.md` with repository, facade, and `config()->{type}()` examples. Explain that typed getters are for non-null settings, `get()`/direct helper reads are for meaningful null or mixed values, and defaults belong in shipped config rather than repeated at callers. -2. Update `src/docs/scheduling.md` to document `cache.schedule_store` / `SCHEDULE_CACHE_STORE` and that null selects the default cache store. -3. Update `src/docs/sentry.md` so `SENTRY_LOG_LEVEL` falls back directly to `LOG_LEVEL`; describe `SENTRY_LOGS_LEVEL` as the removed backwards-compatibility alias for the upstream rename. -4. Add one concise, action-focused entry to the Configuration section of `src/docs/porting-from-laravel.md`: Hypervel requires `database.migrations` to be an array with a required `table` member (`update_date_on_publish` remains optional), requires array-form `logging.deprecations`, uses `cache.schedule_store` / `SCHEDULE_CACHE_STORE`, and does not support Laravel's old `SCHEDULE_CACHE_DRIVER`. -5. In the Providers section of the same guide, change the post-merge Courier singleton example to `$app->make('config')->array('courier')`. Explain that `isEnabled()` runs before the provider's own `mergeConfigFrom()` and therefore may read only already-loaded application/framework config or an intentional fallback. Use `config()->boolean('courier.enabled', false)` in that example so the optional unpublished flag is typed without throwing. +1. Update `src/docs/configuration.md` with repository, facade, and `config()->{type}()` examples. Explain that typed getters are for non-null settings, `get()`/direct helper reads are for meaningful null or mixed values, and defaults belong in shipped config rather than repeated at callers. Document the merge contract: ordinary nested arrays and lists replace as complete values, named registries merge by entry name while each same-named entry replaces completely, and replacing a fixed block requires its complete current schema. Show the actionable missing-key errors from typed reads and direct fixed-record access. +2. Update `src/docs/horizon.md` to document `horizon.env` / `HORIZON_ENV` as the advanced override for selecting a provisioning environment independently of `app.env`; null continues to use the application environment, and the command's `--environment` option takes precedence. +3. Update `src/docs/scheduling.md` to document `cache.schedule_store` / `SCHEDULE_CACHE_STORE` and that null selects the default cache store. +4. Update `src/docs/sentry.md` so `SENTRY_LOG_LEVEL` falls back directly to `LOG_LEVEL`; describe `SENTRY_LOGS_LEVEL` as the removed backwards-compatibility alias for the upstream rename. +5. Add one concise, action-focused entry to the Configuration section of `src/docs/porting-from-laravel.md`: Hypervel requires complete fixed nested config blocks and does not retain Laravel's source defaults for missing members, so porters should start from Hypervel's shipped config and reapply overrides. Name the common current-upstream differences: Laravel Scout's Meilisearch block lacks Hypervel's required `retries` and `initial_retry_delay_ms` members, and Laravel Fortify's limiter block lacks Hypervel's required `verification` member. In the same entry, state that `database.migrations` is an array with required `table` and `update_date_on_publish` members, `logging.deprecations` uses its current complete array shape, scheduling uses `cache.schedule_store` / `SCHEDULE_CACHE_STORE`, and Laravel's old `SCHEDULE_CACHE_DRIVER` is unsupported. +6. In the Providers section of the same guide, change the post-merge Courier singleton example to `$app->make('config')->array('courier')`. Explain that `isEnabled()` runs before the provider's own `mergeConfigFrom()` and therefore may read only already-loaded application/framework config or an intentional fallback. Use `config()->boolean('courier.enabled', false)` in that example so the optional unpublished flag is typed without throwing. Do not duplicate these docs in package READMEs. The removed Sentry env alias is package-specific and does not change a normal Laravel porting decision, so it belongs only in the Sentry documentation. @@ -164,7 +179,7 @@ Do not duplicate these docs in package READMEs. The removed Sentry env alias is 1. Add or correct shipped config definitions and published stubs. 2. Remove the four confirmed Laravel compatibility branches: schedule env alias, scalar migration config, scalar deprecation config, and renamed Sentry env alias. -3. Add and run the regression tests for those contract changes and for the eager passkey-default defects. +3. Add and run the regression tests for those contract changes, the newly declared config surfaces, and the eager passkey-default defects. 4. Convert production access package by package, one file at a time. Update an existing test file only after its production slice is coherent, then run that test file immediately. 5. Add the missing functional null tests and run each file immediately. 6. Make targeted documentation edits. From 7009e7307ec36214e1c47d432f0f42c5248879d0 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:39:59 +0000 Subject: [PATCH 003/109] test: fail the suite on PHP warnings Enable PHPUnit's warning failure mode so undefined config members and similar runtime warnings cannot produce a successful test command. This complements the shipped-record integration tests without changing the treatment of existing deprecations or optional-service skips. --- phpunit.xml.dist | 1 + 1 file changed, 1 insertion(+) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 2fffc0879..51a53f3ce 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -6,6 +6,7 @@ defaultTimeLimit="60" enforceTimeLimit="true" failOnRisky="true" + failOnWarning="true" processIsolation="false" stopOnError="false" stopOnFailure="false" From 6578f61f5d569a29d508a63f8fb46ec027f82855 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:40:07 +0000 Subject: [PATCH 004/109] config: declare complete framework configuration schemas Make the shipped application, auth, broadcasting, cache, database, filesystem, logging, mail, queue, and session records the canonical definitions for stable first-party settings. Document nullable inheritance and disabled states at their owning config sections, and normalize env-backed scalar values before typed consumers read them. Extend configuration loading coverage for the new schedule store and schema members, including the removal of the legacy schedule cache environment name. --- src/foundation/config/app.php | 29 ++++- src/foundation/config/auth.php | 32 +++++- src/foundation/config/broadcasting.php | 4 + src/foundation/config/cache.php | 31 +++++ src/foundation/config/database.php | 58 +++++++++- src/foundation/config/filesystems.php | 27 +++++ src/foundation/config/logging.php | 24 +++- src/foundation/config/mail.php | 22 +++- src/foundation/config/queue.php | 27 +++++ src/foundation/config/session.php | 8 +- tests/Foundation/Fixtures/config/app.php | 2 + tests/Foundation/Fixtures/envs/.env | 1 + tests/Foundation/Fixtures/envs/.env.testing | 1 + tests/Foundation/FoundationConfigTest.php | 120 ++++++++++++++++---- 14 files changed, 350 insertions(+), 36 deletions(-) diff --git a/src/foundation/config/app.php b/src/foundation/config/app.php index c985383d7..4e090afa3 100644 --- a/src/foundation/config/app.php +++ b/src/foundation/config/app.php @@ -63,6 +63,19 @@ 'debug' => (bool) env('APP_DEBUG', false), + /* + |-------------------------------------------------------------------------- + | Source Editor + |-------------------------------------------------------------------------- + | + | This setting controls clickable source links on development exception + | pages. Set it to an editor name or an array of editor options. A null + | value disables source links. + | + */ + + 'editor' => null, + /* |-------------------------------------------------------------------------- | Stdout Log Configuration @@ -122,9 +135,15 @@ | Application URL |-------------------------------------------------------------------------- | - | This URL is used by the console to properly generate URLs when using - | the Artisan command line tool. You should set this to the root of - | the application so that it's available within Artisan commands. + | This URL is used whenever Hypervel needs to know the root of your + | application: generating URLs in console commands, queued jobs, and + | mail, and deriving trusted host patterns for incoming requests. You + | should set this to the root of your application. + | + | Set this to null only when the application has no canonical URL. + | Features that support a missing URL use their documented behavior, + | while features that require an absolute URL fail until a URL is + | configured. | */ @@ -144,11 +163,15 @@ | Here you may specify the default timezone for your application, which | will be used by the PHP date and date-time functions. The timezone | is set to "UTC" by default as it is suitable for most use cases. + | Set the schedule timezone to null to use the application timezone for + | scheduled tasks. | */ 'timezone' => env('APP_TIMEZONE', 'UTC'), + 'schedule_timezone' => null, + /* |-------------------------------------------------------------------------- | Application Locale Configuration diff --git a/src/foundation/config/auth.php b/src/foundation/config/auth.php index fd211d5c0..28d30ff80 100644 --- a/src/foundation/config/auth.php +++ b/src/foundation/config/auth.php @@ -31,12 +31,22 @@ | users are actually retrieved out of your database or other storage | system used by the application. Typically, Eloquent is utilized. | - | Guards that send password reset links declare their broker with - | the "passwords" key, referencing an entry in the passwords array. + | Guards that send password reset links declare their broker with the + | "passwords" key, referencing an entry in the passwords array. Set it + | to null when the guard does not select a default password broker. | Sanctum guards declare the session guards they trust for first-party | SPA requests with the "session_guards" key; set it to an empty array - | for bearer-token-only APIs. Guards may also override the password - | confirmation window with a "password_timeout" key. + | for bearer-token-only APIs. Set "password_timeout" to null to inherit + | the application-wide password confirmation window. Session guards may + | set "remember" to a lifetime in minutes; null keeps the built-in + | lifetime. JWT guards may set "ttl" to an integer number of minutes, + | null for non-expiring tokens, or "inherit" to use the global jwt.ttl. + | The "inherit" value is specific to JWT guard records because null has + | its own non-expiring meaning. + | + | Token guards require "provider", "input_key", "storage_key", and + | "hash" members. Request and custom guards may use a null provider when + | their implementation does not retrieve users through a provider. | | Supported by default: "session". Install hypervel/sanctum to use | the "sanctum" guard, and hypervel/jwt to use the "jwt" guard @@ -48,15 +58,22 @@ 'driver' => 'session', 'provider' => 'users', 'passwords' => 'users', + 'password_timeout' => null, + 'remember' => null, ], 'sanctum' => [ 'driver' => 'sanctum', 'provider' => 'users', 'session_guards' => ['web'], + 'passwords' => null, + 'password_timeout' => null, ], 'jwt' => [ 'driver' => 'jwt', 'provider' => 'users', + 'passwords' => null, + 'password_timeout' => null, + 'ttl' => 'inherit', ], ], @@ -161,12 +178,19 @@ | generating more password reset tokens. This prevents the user from | quickly generating a very large amount of password reset tokens. | + | Database brokers require "driver", "provider", "table", "connection", + | "expire", and "throttle". Set "connection" to null to use the default + | database connection. Cache brokers replace "table" and "connection" + | with a nullable "store" member; null selects the default cache store. + | */ 'passwords' => [ 'users' => [ + 'driver' => 'database', 'provider' => 'users', 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), + 'connection' => null, 'expire' => 60, 'throttle' => 60, ], diff --git a/src/foundation/config/broadcasting.php b/src/foundation/config/broadcasting.php index a8890f288..d20cfefee 100644 --- a/src/foundation/config/broadcasting.php +++ b/src/foundation/config/broadcasting.php @@ -26,6 +26,8 @@ | Here you may define all of the broadcast connections that will be used | to broadcast events to other systems or over WebSockets. Samples of | each available type of connection are provided inside this array. + | The Pusher-compatible "log" option controls SDK logging and is + | separate from the broadcast connection that uses the log driver. | */ @@ -45,6 +47,7 @@ 'client_options' => [ // Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html ], + 'log' => false, 'jsonp' => false, ], @@ -64,6 +67,7 @@ 'client_options' => [ // Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html ], + 'log' => false, 'jsonp' => false, ], diff --git a/src/foundation/config/cache.php b/src/foundation/config/cache.php index 6404a33ed..3a1d91fb9 100644 --- a/src/foundation/config/cache.php +++ b/src/foundation/config/cache.php @@ -18,6 +18,18 @@ 'default' => env('CACHE_STORE', 'database'), + /* + |-------------------------------------------------------------------------- + | Schedule Cache Store + |-------------------------------------------------------------------------- + | + | This store coordinates scheduled tasks across processes and servers. + | Set it to null to use the default cache store. + | + */ + + 'schedule_store' => env('SCHEDULE_CACHE_STORE'), + /* |-------------------------------------------------------------------------- | Cache Stores @@ -31,44 +43,58 @@ | "storage", "redis", "swoole", "stack", "session", | "failover", "null" | + | A null store prefix inherits the global cache prefix. Nullable connection, + | lock connection, and disk values select their manager's default. A null + | file permission uses the operating system's default permissions. Any store + | may set "events" to false to disable cache events for that repository. + | */ 'stores' => [ 'array' => [ 'driver' => 'array', 'serialize' => false, + 'events' => true, ], 'worker-array' => [ 'driver' => 'worker-array', 'serialize' => false, + 'events' => true, ], 'session' => [ 'driver' => 'session', 'key' => env('SESSION_CACHE_KEY', '_cache'), + 'events' => true, ], 'database' => [ 'driver' => 'database', 'connection' => env('DB_CACHE_CONNECTION'), 'table' => env('DB_CACHE_TABLE', 'cache'), + 'prefix' => null, 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'), 'lock_table' => env('DB_CACHE_LOCK_TABLE', 'cache_locks'), 'lock_lottery' => [2, 100], 'lock_timeout' => 86400, + 'events' => true, ], 'file' => [ 'driver' => 'file', 'path' => storage_path('framework/cache/data'), + 'permission' => null, 'lock_path' => storage_path('framework/cache/data'), + 'events' => true, ], 'storage' => [ 'driver' => 'storage', 'disk' => env('CACHE_STORAGE_DISK'), 'path' => env('CACHE_STORAGE_PATH', 'framework/cache/data'), + 'prefix' => null, + 'events' => true, ], 'redis' => [ @@ -76,6 +102,8 @@ 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'), 'tag_mode' => env('REDIS_CACHE_TAG_MODE', 'all'), // 'any' requires PhpRedis 6.3.0+ with Redis 8.0+ or Valkey 9.0+. 'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'cache'), + 'prefix' => null, + 'events' => true, ], 'swoole' => [ @@ -86,6 +114,7 @@ 'eviction_proportion' => 0.05, 'eviction_interval' => 10000, // milliseconds 'interval_refresh_interval' => 1000, // milliseconds + 'events' => true, ], 'stack' => [ @@ -96,6 +125,7 @@ ], 'redis', ], + 'events' => true, ], 'failover' => [ @@ -104,6 +134,7 @@ 'database', 'array', ], + 'events' => true, ], ], diff --git a/src/foundation/config/database.php b/src/foundation/config/database.php index be71b8ffa..def1bf396 100644 --- a/src/foundation/config/database.php +++ b/src/foundation/config/database.php @@ -36,6 +36,9 @@ | Below are all of the database connections defined for your application. | An example configuration is provided for each database system which | is supported by Hypervel. You're free to add / remove connections. + | A connection may set "migrations_connection" to route migrations + | through another named connection. When omitted, migrations use the + | selected connection itself. | */ @@ -195,7 +198,15 @@ | | Redis is an open source, fast, and advanced key-value store that also | provides a richer body of commands than a typical key-value system - | such as Memcached. You may define your connection settings here. + | such as Memcached. Each named connection must define one complete + | standalone, Sentinel, or Cluster record. + | + | A null scheme leaves transport selection to the URL, stream context, + | or Redis default. A null connection name disables CLIENT SETNAME, a + | null timeout inherits the pool connection timeout, and a null prefix + | inherits the shared prefix. Sentinel and Cluster records must define + | their complete topology blocks; Cluster records omit standalone-only + | URL, host, port, database, name, and retry interval settings. | */ @@ -206,11 +217,20 @@ 'default' => [ 'url' => env('REDIS_URL'), + 'scheme' => null, 'host' => env('REDIS_HOST', 'localhost'), 'username' => env('REDIS_USERNAME'), 'password' => env('REDIS_PASSWORD'), 'port' => (int) env('REDIS_PORT', 6379), 'database' => (int) env('REDIS_DB', 0), + 'name' => null, + 'timeout' => null, + 'retry_interval' => 0, + 'read_timeout' => 0.0, + 'context' => [], + 'options' => [], + 'prefix' => null, + 'events' => false, 'max_retries' => (int) env('REDIS_MAX_RETRIES', 3), 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), 'backoff_base' => (int) env('REDIS_BACKOFF_BASE', 100), @@ -229,11 +249,20 @@ 'cache' => [ 'url' => env('REDIS_CACHE_URL', env('REDIS_URL')), + 'scheme' => null, 'host' => env('REDIS_CACHE_HOST', env('REDIS_HOST', 'localhost')), 'username' => env('REDIS_CACHE_USERNAME', env('REDIS_USERNAME')), 'password' => env('REDIS_CACHE_PASSWORD', env('REDIS_PASSWORD')), 'port' => (int) env('REDIS_CACHE_PORT', env('REDIS_PORT', 6379)), 'database' => (int) env('REDIS_CACHE_DB', env('REDIS_DB', 0)), + 'name' => null, + 'timeout' => null, + 'retry_interval' => 0, + 'read_timeout' => 0.0, + 'context' => [], + 'options' => [], + 'prefix' => null, + 'events' => false, 'max_retries' => (int) env('REDIS_CACHE_MAX_RETRIES', env('REDIS_MAX_RETRIES', 3)), 'backoff_algorithm' => env('REDIS_CACHE_BACKOFF_ALGORITHM', env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter')), 'backoff_base' => (int) env('REDIS_CACHE_BACKOFF_BASE', env('REDIS_BACKOFF_BASE', 100)), @@ -252,11 +281,20 @@ 'session' => [ 'url' => env('REDIS_SESSION_URL', env('REDIS_URL')), + 'scheme' => null, 'host' => env('REDIS_SESSION_HOST', env('REDIS_HOST', 'localhost')), 'username' => env('REDIS_SESSION_USERNAME', env('REDIS_USERNAME')), 'password' => env('REDIS_SESSION_PASSWORD', env('REDIS_PASSWORD')), 'port' => (int) env('REDIS_SESSION_PORT', env('REDIS_PORT', 6379)), 'database' => (int) env('REDIS_SESSION_DB', env('REDIS_DB', 0)), + 'name' => null, + 'timeout' => null, + 'retry_interval' => 0, + 'read_timeout' => 0.0, + 'context' => [], + 'options' => [], + 'prefix' => null, + 'events' => false, 'max_retries' => (int) env('REDIS_SESSION_MAX_RETRIES', env('REDIS_MAX_RETRIES', 3)), 'backoff_algorithm' => env('REDIS_SESSION_BACKOFF_ALGORITHM', env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter')), 'backoff_base' => (int) env('REDIS_SESSION_BACKOFF_BASE', env('REDIS_BACKOFF_BASE', 100)), @@ -275,11 +313,20 @@ 'queue' => [ 'url' => env('REDIS_QUEUE_URL', env('REDIS_URL')), + 'scheme' => null, 'host' => env('REDIS_QUEUE_HOST', env('REDIS_HOST', 'localhost')), 'username' => env('REDIS_QUEUE_USERNAME', env('REDIS_USERNAME')), 'password' => env('REDIS_QUEUE_PASSWORD', env('REDIS_PASSWORD')), 'port' => (int) env('REDIS_QUEUE_PORT', env('REDIS_PORT', 6379)), 'database' => (int) env('REDIS_QUEUE_DB', env('REDIS_DB', 0)), + 'name' => null, + 'timeout' => null, + 'retry_interval' => 0, + 'read_timeout' => 0.0, + 'context' => [], + 'options' => [], + 'prefix' => null, + 'events' => false, 'max_retries' => (int) env('REDIS_QUEUE_MAX_RETRIES', env('REDIS_MAX_RETRIES', 3)), 'backoff_algorithm' => env('REDIS_QUEUE_BACKOFF_ALGORITHM', env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter')), 'backoff_base' => (int) env('REDIS_QUEUE_BACKOFF_BASE', env('REDIS_BACKOFF_BASE', 100)), @@ -298,11 +345,20 @@ 'reverb' => [ 'url' => env('REDIS_REVERB_URL', env('REDIS_URL')), + 'scheme' => null, 'host' => env('REDIS_REVERB_HOST', env('REDIS_HOST', 'localhost')), 'username' => env('REDIS_REVERB_USERNAME', env('REDIS_USERNAME')), 'password' => env('REDIS_REVERB_PASSWORD', env('REDIS_PASSWORD')), 'port' => (int) env('REDIS_REVERB_PORT', env('REDIS_PORT', 6379)), 'database' => (int) env('REDIS_REVERB_DB', env('REDIS_DB', 0)), + 'name' => null, + 'timeout' => null, + 'retry_interval' => 0, + 'read_timeout' => 0.0, + 'context' => [], + 'options' => [], + 'prefix' => null, + 'events' => false, 'max_retries' => (int) env('REDIS_REVERB_MAX_RETRIES', env('REDIS_MAX_RETRIES', 3)), 'backoff_algorithm' => env('REDIS_REVERB_BACKOFF_ALGORITHM', env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter')), 'backoff_base' => (int) env('REDIS_REVERB_BACKOFF_BASE', env('REDIS_BACKOFF_BASE', 100)), diff --git a/src/foundation/config/filesystems.php b/src/foundation/config/filesystems.php index 35f17f09b..b3967cda8 100644 --- a/src/foundation/config/filesystems.php +++ b/src/foundation/config/filesystems.php @@ -27,12 +27,24 @@ | | Supported drivers: "local", "ftp", "sftp", "s3", "gcs" | + | Built-in disk records below declare their driver-specific settings. + | Any disk may also use Flysystem's shared visibility, URL, prefix, and + | read-only options. A null directory visibility inherits the file + | visibility. A null local links setting disallows symbolic links. + | */ 'disks' => [ 'local' => [ 'driver' => 'local', 'root' => storage_path('app/private'), + 'permissions' => [], + 'visibility' => 'private', + 'directory_visibility' => null, + 'lock' => LOCK_EX, + 'links' => null, + 'serve' => false, + 'read-only' => false, 'throw' => false, ], @@ -40,7 +52,13 @@ 'driver' => 'local', 'root' => storage_path('app/public'), 'url' => env('APP_URL') . '/storage', + 'permissions' => [], 'visibility' => 'public', + 'directory_visibility' => null, + 'lock' => LOCK_EX, + 'links' => null, + 'serve' => false, + 'read-only' => false, 'throw' => false, ], @@ -48,11 +66,18 @@ 'driver' => 's3', 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'token' => null, 'region' => env('AWS_DEFAULT_REGION'), 'bucket' => env('AWS_BUCKET'), + 'root' => '', 'url' => env('AWS_URL'), 'endpoint' => env('AWS_ENDPOINT'), 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), + 'version' => 'latest', + 'visibility' => 'public', + 'options' => [], + 'client' => [], + 'read-only' => false, 'throw' => false, 'stream_reads' => true, 'pool' => [ @@ -77,6 +102,8 @@ 'visibility' => 'public', // optional: public|private 'visibility_handler' => null, // optional: set to \League\Flysystem\GoogleCloudStorage\UniformBucketLevelAccessVisibility::class to enable uniform bucket level access 'metadata' => ['cacheControl' => 'public,max-age=86400'], // optional: default metadata + 'client' => [], + 'read-only' => false, 'throw' => false, 'stream_reads' => true, 'pool' => [ diff --git a/src/foundation/config/logging.php b/src/foundation/config/logging.php index cb84b6fa8..a707be094 100644 --- a/src/foundation/config/logging.php +++ b/src/foundation/config/logging.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Monolog\Handler\ErrorLogHandler; use Monolog\Handler\NullHandler; use Monolog\Handler\StreamHandler; use Monolog\Handler\SyslogUdpHandler; @@ -34,7 +35,7 @@ 'deprecations' => [ 'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), - 'trace' => env('LOG_DEPRECATIONS_TRACE', false), + 'trace' => (bool) env('LOG_DEPRECATIONS_TRACE', false), ], /* @@ -49,6 +50,11 @@ | Available drivers: "single", "daily", "slack", "syslog", | "errorlog", "monolog", "custom", "stack" | + | Built-in channel records below declare their driver-specific settings. + | Any channel may also set a custom "name", a "tap" list, or optional + | formatter and action-level settings. A null file permission uses the + | operating system's default permissions. + | */ 'channels' => [ @@ -62,6 +68,9 @@ 'driver' => 'single', 'path' => storage_path('logs/hypervel.log'), 'level' => env('LOG_LEVEL', 'debug'), + 'bubble' => true, + 'permission' => null, + 'locking' => false, 'replace_placeholders' => true, ], @@ -70,15 +79,24 @@ 'path' => storage_path('logs/hypervel.log'), 'level' => env('LOG_LEVEL', 'debug'), 'days' => env('LOG_DAILY_DAYS', 14), + 'bubble' => true, + 'permission' => null, + 'locking' => false, 'replace_placeholders' => true, ], 'slack' => [ 'driver' => 'slack', 'url' => env('LOG_SLACK_WEBHOOK_URL'), + 'channel' => null, 'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Hypervel')), + 'attachment' => true, 'emoji' => env('LOG_SLACK_EMOJI', ':boom:'), + 'short' => false, + 'context' => true, 'level' => env('LOG_LEVEL', 'critical'), + 'bubble' => true, + 'exclude_fields' => [], 'replace_placeholders' => true, ], @@ -126,12 +144,16 @@ 'errorlog' => [ 'driver' => 'errorlog', 'level' => env('LOG_LEVEL', 'debug'), + 'type' => ErrorLogHandler::OPERATING_SYSTEM, 'replace_placeholders' => true, ], 'null' => [ 'driver' => 'monolog', + 'level' => 'debug', 'handler' => NullHandler::class, + 'handler_with' => [], + 'processors' => [], ], 'emergency' => [ diff --git a/src/foundation/config/mail.php b/src/foundation/config/mail.php index 9752f2dda..b5a80c142 100644 --- a/src/foundation/config/mail.php +++ b/src/foundation/config/mail.php @@ -34,6 +34,11 @@ | "postmark", "resend", "cloudflare", "log", | "array", "failover", "roundrobin" | + | A null SMTP scheme is inferred from its port, while a null timeout leaves + | the transport default unchanged. A null log channel uses the default log + | channel. Poolable named transports use the shared pool defaults; set + | "pool" to false to disable pooling or provide an array of pool options. + | */ 'mailers' => [ @@ -46,32 +51,39 @@ 'username' => env('MAIL_USERNAME'), 'password' => env('MAIL_PASSWORD'), 'timeout' => null, + 'source_ip' => null, 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)), + 'pool' => [], ], 'ses' => [ 'transport' => 'ses-v2', + 'options' => [], + 'pool' => [], ], 'postmark' => [ 'transport' => 'postmark', - // 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'), - // 'client' => [ - // 'timeout' => 5, - // ], + 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'), + 'client' => [], + 'pool' => [], ], 'resend' => [ 'transport' => 'resend', + 'pool' => [], ], 'cloudflare' => [ 'transport' => 'cloudflare', + 'client' => [], + 'pool' => [], ], 'sendmail' => [ 'transport' => 'sendmail', 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'), + 'pool' => [], ], 'log' => [ @@ -90,6 +102,7 @@ 'log', ], 'retry_after' => 60, + 'pool' => [], ], 'roundrobin' => [ @@ -99,6 +112,7 @@ 'postmark', ], 'retry_after' => 60, + 'pool' => [], ], ], diff --git a/src/foundation/config/queue.php b/src/foundation/config/queue.php index 310edbb27..faff2c784 100644 --- a/src/foundation/config/queue.php +++ b/src/foundation/config/queue.php @@ -38,19 +38,32 @@ | | Drivers: "sync", "background", "deferred", "database", "beanstalkd", "sqs", "redis", "null" | + | A null database connection selects the default database connection. A + | null Beanstalkd timeout disables the socket timeout. Connection records + | for drivers without named queues may omit the "queue" member. + | + | For SQS, a non-null credentials option takes precedence over the static + | key and secret. When all three are null, the AWS SDK uses its default + | credential chain. A null token means no temporary AWS session token. + | Callable or object credentials require an explicit pool fingerprint. + | The version and HTTP options configure the underlying AWS SDK client. + | */ 'connections' => [ 'sync' => [ 'driver' => 'sync', + 'after_commit' => false, ], 'background' => [ 'driver' => 'background', + 'after_commit' => false, ], 'deferred' => [ 'driver' => 'deferred', + 'after_commit' => false, ], 'database' => [ @@ -65,9 +78,11 @@ 'beanstalkd' => [ 'driver' => 'beanstalkd', 'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'), + 'port' => 11300, 'queue' => env('BEANSTALKD_QUEUE', 'default'), 'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90), 'block_for' => 0, + 'timeout' => null, 'after_commit' => false, 'pool' => [ 'min_retained_objects' => 1, @@ -83,10 +98,17 @@ 'driver' => 'sqs', 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'token' => null, + 'credentials' => null, 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), 'queue' => env('SQS_QUEUE', 'default'), 'suffix' => env('SQS_SUFFIX'), 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'version' => 'latest', + 'http' => [ + 'timeout' => 60, + 'connect_timeout' => 60, + ], 'after_commit' => false, 'overflow' => [ 'enabled' => env('SQS_OVERFLOW_ENABLED', false), @@ -112,6 +134,7 @@ 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90), 'block_for' => null, 'after_commit' => false, + 'migration_batch_size' => -1, ], ], @@ -142,6 +165,10 @@ | | Supported drivers: "database", "database-uuids", "file", "null" | + | Database drivers require "database" and "table". The file driver uses + | a "path" and "limit" instead; omitting them stores up to 100 failures in + | storage/framework/cache/failed-jobs.json. + | */ 'failed' => [ diff --git a/src/foundation/config/session.php b/src/foundation/config/session.php index ad4da05fe..4ecb3f0a7 100644 --- a/src/foundation/config/session.php +++ b/src/foundation/config/session.php @@ -68,6 +68,7 @@ | When using the "database" or "redis" session drivers, you may specify a | connection that should be used to manage these sessions. This should | correspond to a connection in the matching driver configuration. + | Set it to null to use that driver's default connection. | */ @@ -119,7 +120,8 @@ | | Session blocking prevents concurrent requests for the same session | from executing at the same time. You may configure the cache store - | and time limits used to acquire and maintain the session lock. + | and time limits used to acquire and maintain the session lock. Set the + | block store to null to use the default cache store. | */ @@ -176,8 +178,8 @@ |-------------------------------------------------------------------------- | | This value determines the domain and subdomains the session cookie is - | available to. By default, the cookie will be available to the root - | domain and all subdomains. Typically, this shouldn't be changed. + | available to. A null value creates a host-only cookie. Set an explicit + | domain when the cookie should be shared with subdomains. | */ diff --git a/tests/Foundation/Fixtures/config/app.php b/tests/Foundation/Fixtures/config/app.php index 8a25d0380..9cd85f222 100644 --- a/tests/Foundation/Fixtures/config/app.php +++ b/tests/Foundation/Fixtures/config/app.php @@ -3,5 +3,7 @@ declare(strict_types=1); return [ + 'env' => 'testing', 'foo' => 'bar', + 'timezone' => 'UTC', ]; diff --git a/tests/Foundation/Fixtures/envs/.env b/tests/Foundation/Fixtures/envs/.env index fabc90024..4193c242a 100644 --- a/tests/Foundation/Fixtures/envs/.env +++ b/tests/Foundation/Fixtures/envs/.env @@ -1,3 +1,4 @@ APP_NAME=Hypervel TEST_KEY=default_value +PASSKEYS_TIMEOUT=1000 FOO=BAR diff --git a/tests/Foundation/Fixtures/envs/.env.testing b/tests/Foundation/Fixtures/envs/.env.testing index 67a7e5889..a868d7b9b 100644 --- a/tests/Foundation/Fixtures/envs/.env.testing +++ b/tests/Foundation/Fixtures/envs/.env.testing @@ -1,2 +1,3 @@ APP_NAME=HypervelTesting TEST_KEY=testing_value +PASSKEYS_TIMEOUT=2000 diff --git a/tests/Foundation/FoundationConfigTest.php b/tests/Foundation/FoundationConfigTest.php index 06fb56eef..bbdbd7c3c 100644 --- a/tests/Foundation/FoundationConfigTest.php +++ b/tests/Foundation/FoundationConfigTest.php @@ -7,8 +7,9 @@ use Closure; use Hypervel\Container\Container; use Hypervel\Foundation\Application; +use Hypervel\Redis\RedisConfig; use Hypervel\Support\Env; -use Hypervel\Tests\TestCase; +use Hypervel\Testbench\TestCase; use Swoole\Constant; class FoundationConfigTest extends TestCase @@ -34,6 +35,91 @@ public function testAppConfigTreatsNullPreviousKeysAsAnEmptyList(): void $this->assertSame([], $config['previous_keys']); } + public function testCacheConfigReadsTheScheduleCacheStoreEnvironmentVariable(): void + { + $config = $this->withEnvironmentValue( + 'SCHEDULE_CACHE_STORE', + 'scheduling', + fn (): array => $this->cacheConfig(), + ); + + $this->assertSame('scheduling', $config['schedule_store']); + } + + public function testCacheConfigIgnoresTheLegacyScheduleCacheDriverEnvironmentVariable(): void + { + $config = $this->withEnvironmentValue( + 'SCHEDULE_CACHE_DRIVER', + 'legacy', + fn (): array => $this->withEnvironmentValue( + 'SCHEDULE_CACHE_STORE', + null, + fn (): array => $this->cacheConfig(), + ), + ); + + $this->assertNull($config['schedule_store']); + } + + public function testShippedCacheStoreEnablesRepositoryEvents(): void + { + $this->assertTrue(config()->boolean('cache.stores.array.events')); + $this->assertNotNull($this->app->make('cache')->store('array')->getEventDispatcher()); + } + + public function testShippedRedisConnectionsUseTheCompleteStandaloneSchema(): void + { + $requiredMembers = [ + 'url', + 'scheme', + 'host', + 'username', + 'password', + 'port', + 'database', + 'name', + 'timeout', + 'retry_interval', + 'read_timeout', + 'context', + 'options', + 'prefix', + 'events', + 'max_retries', + 'backoff_algorithm', + 'backoff_base', + 'backoff_cap', + 'pool', + ]; + $requiredPoolMembers = [ + 'min_connections', + 'max_connections', + 'connect_timeout', + 'wait_timeout', + 'heartbeat', + 'heartbeat_timeout', + 'max_idle_time', + 'max_lifetime', + ]; + $redisConfig = $this->app->make(RedisConfig::class); + $sharedPrefix = config()->string('database.redis.options.prefix'); + + foreach (['default', 'cache', 'session', 'queue', 'reverb'] as $name) { + $connection = config()->array("database.redis.{$name}"); + + $this->assertSame([], array_diff($requiredMembers, array_keys($connection))); + $this->assertSame([], array_diff($requiredPoolMembers, array_keys($connection['pool']))); + $this->assertNull($connection['name']); + $this->assertNull($connection['timeout']); + $this->assertNull($connection['prefix']); + $this->assertFalse($connection['events']); + $this->assertSame( + $sharedPrefix, + $redisConfig->connectionConfig($name)['options']['prefix'], + ); + } + } + public function testServerConfigUsesSafeTaskDefaults(): void { $config = $this->serverConfig(); @@ -79,7 +165,9 @@ public function testBroadcastingConfigDisablesJsonpAndDoesNotShipSdkPools(): voi $config = require dirname(__DIR__, 2) . '/src/foundation/config/broadcasting.php'; $this->assertFalse($config['connections']['reverb']['jsonp']); + $this->assertFalse($config['connections']['reverb']['log']); $this->assertFalse($config['connections']['pusher']['jsonp']); + $this->assertFalse($config['connections']['pusher']['log']); $this->assertArrayNotHasKey('pool', $config['connections']['pusher']); $this->assertArrayNotHasKey('pool', $config['connections']['ably']); } @@ -110,15 +198,7 @@ public function testViewCompiledPathFallsBackToStoragePathWhenDirectoryDoesNotEx public function testViewConfigDefinesCompilerDefaults(): void { - $originalContainer = Container::getInstance(); - - try { - Container::setInstance(new Application(dirname(__DIR__, 2))); - - $config = require dirname(__DIR__, 2) . '/src/foundation/config/view.php'; - } finally { - Container::setInstance($originalContainer); - } + $config = require dirname(__DIR__, 2) . '/src/foundation/config/view.php'; $this->assertFalse($config['relative_hash']); $this->assertTrue($config['cache']); @@ -137,19 +217,19 @@ protected function appConfigWithEnvironment(string $key, string $value): array } /** - * Load the server configuration with an application instance. + * Load the cache configuration. */ - protected function serverConfig(): array + protected function cacheConfig(): array { - $originalContainer = Container::getInstance(); - - try { - Container::setInstance(new Application(dirname(__DIR__, 2))); + return require dirname(__DIR__, 2) . '/src/foundation/config/cache.php'; + } - return require dirname(__DIR__, 2) . '/src/foundation/config/server.php'; - } finally { - Container::setInstance($originalContainer); - } + /** + * Load the server configuration. + */ + protected function serverConfig(): array + { + return require dirname(__DIR__, 2) . '/src/foundation/config/server.php'; } /** From eee35a1abf916d308e6fea7850b41155ec0f7353 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:40:24 +0000 Subject: [PATCH 005/109] foundation: require canonical application configuration Read required application, view, provider, alias, migration-publishing, and error-rendering settings through typed configuration APIs without duplicate framework defaults. Preserve supported nullable URL, editor, domain, and maintenance-mode behavior at the consumers that own it. Document the typed getter and shallow merge contracts, update provider examples for pre-merge configuration, and add regressions for missing required values, nullable application URLs, reload behavior, and Testbench integration. --- src/docs/blade.md | 2 +- src/docs/configuration.md | 45 +++++++++++++++---- src/docs/packages.md | 12 ++++- src/docs/providers.md | 6 ++- src/docs/requests.md | 2 +- src/docs/routing.md | 2 +- .../renderer/components/layout.blade.php | 2 +- .../src/Bootstrap/LoadConfiguration.php | 4 +- src/foundation/src/Console/AboutCommand.php | 40 ++++++++--------- src/foundation/src/Exceptions/Handler.php | 12 ++--- .../src/Exceptions/RegisterErrorViewPaths.php | 3 +- .../src/Http/MaintenanceModeBypassCookie.php | 2 +- .../Http/Middleware/PreventRequestForgery.php | 6 +-- .../src/resources/health-up.blade.php | 2 +- src/support/src/ServiceProvider.php | 10 ++--- .../src/Concerns/CreatesApplication.php | 4 +- src/testbench/src/Workbench/Workbench.php | 2 +- .../Bootstrap/LoadConfigurationTest.php | 22 ++++----- .../FoundationExceptionsHandlerTest.php | 5 ++- .../Listeners/ReloadDotenvAndConfigTest.php | 1 - tests/Foundation/StaticStateTest.php | 8 +++- .../Foundation/Console/AboutCommandTest.php | 14 ++++++ .../Foundation/MaintenanceModeTest.php | 9 ++++ tests/Support/SupportServiceProviderTest.php | 21 ++++++++- .../Concerns/CreatesApplicationTest.php | 2 +- 25 files changed, 160 insertions(+), 78 deletions(-) diff --git a/src/docs/blade.md b/src/docs/blade.md index a1590f4f1..45c8434ae 100644 --- a/src/docs/blade.md +++ b/src/docs/blade.md @@ -2068,7 +2068,7 @@ use Hypervel\Support\Facades\Blade; public function boot(): void { Blade::if('disk', function (string $value) { - return config('filesystems.default') === $value; + return config()->string('filesystems.default') === $value; }); } ``` diff --git a/src/docs/configuration.md b/src/docs/configuration.md index a962fef06..a5013eb40 100644 --- a/src/docs/configuration.md +++ b/src/docs/configuration.md @@ -8,6 +8,8 @@ - [Determining the Current Environment](#determining-the-current-environment) - [Encrypting Environment Files](#encrypting-environment-files) - [Accessing Configuration Values](#accessing-configuration-values) + - [Typed Configuration Values](#typed-configuration-values) + - [Merging Framework Defaults](#merging-framework-defaults) - [Configuration Caching](#configuration-caching) - [Configuration Publishing](#configuration-publishing) - [Debug Mode](#debug-mode) @@ -20,7 +22,7 @@ All of the configuration files for the Hypervel framework are stored in the `con These configuration files allow you to configure things like your database connection information, your mail server information, as well as various other core configuration values such as your application URL and encryption key. -Hypervel ships framework configuration defaults with the framework itself. Your application's `config` files override those defaults, while common nested options like authentication guards and providers, broadcasting connections, cache stores, database connections and Redis settings, filesystem disks, logging channels, mailers, and queue connections are merged so you may customize only the values you need. +Hypervel ships framework configuration defaults with the framework itself. Your application's `config` files override those defaults. Ordinary nested arrays and lists replace the framework value completely. Named registries such as authentication guards, cache stores, database connections, filesystem disks, log channels, mailers, and queue connections merge by entry name, but an application entry replaces the complete framework entry with the same name. > [!NOTE] > Configuration files are loaded in alphabetical order during application bootstrap. Cross-references via the `config` function depend on file load order, so you should prefer `env` or local PHP values inside configuration files. @@ -302,17 +304,44 @@ config(['app.timezone' => 'America/Chicago']); > [!WARNING] > In Hypervel's Swoole workers, runtime configuration mutations are process-global within the worker. Every concurrent coroutine in that worker may observe the changed value, so you should only mutate configuration during bootstrapping or tests. For request-specific state, use request data, middleware-managed state, or coroutine context instead. -To assist with static analysis, the `Config` facade also provides typed configuration retrieval methods. If the retrieved configuration value does not match the expected type, an exception will be thrown: + +### Typed Configuration Values + +Typed configuration methods are useful when a value must contain one particular type. These methods are available through an injected configuration repository, the `Config` facade, and the `config()` helper: + +```php +use Hypervel\Contracts\Config\Repository; +use Hypervel\Support\Facades\Config; + +function settings(Repository $config): array +{ + return [ + 'timezone' => $config->string('app.timezone'), + 'debug' => Config::boolean('app.debug'), + 'providers' => config()->array('app.providers'), + ]; +} +``` + +The available methods are `string`, `integer`, `float`, `boolean`, `array`, and `collection`. When a key is missing or contains the wrong type, Hypervel will throw an `InvalidArgumentException` that names the full configuration key. + +Some configuration values intentionally allow null or more than one type. You should retrieve these values using `get()` or the direct `config('key')` form. For example, a null editor disables source links: ```php -Config::string('config-key'); -Config::integer('config-key'); -Config::float('config-key'); -Config::boolean('config-key'); -Config::array('config-key'); -Config::collection('config-key'); +$editor = config('app.editor'); ``` +Options that support null are still listed in their configuration file. The comments in that file explain whether null inherits another option, disables a feature, or is checked when the feature is used. Whenever an option has a default value, define it in the configuration file instead of repeating it each time the option is read. Otherwise, a missing or misspelled key could silently use the repeated default. + + +### Merging Framework Defaults + +When your application replaces a nested configuration array, the application array replaces the entire framework array. Hypervel does not recursively merge its individual options. Therefore, your array should contain every option from the current framework configuration. Lists are also replaced completely, so an empty application list may intentionally clear a framework list. + +For named groups such as database connections, cache stores, filesystem disks, log channels, mailers, and queue connections, Hypervel merges the names while replacing the contents of any matching name. For example, adding a new database connection preserves the other connections. However, replacing `database.connections.mysql` replaces the entire MySQL connection array. Start with Hypervel's provided configuration for the selected driver, then apply your changes. If a required option is missing, Hypervel will report the missing option instead of quietly supplying another default. + +This replacement behavior applies to named application configuration. Public methods such as `Cache::build()`, `Storage::build()`, `Log::build()`, and `Mail::build()` also accept arrays created directly in your application. These methods continue to supply their documented defaults when an optional value is omitted from such an array. + ## Configuration Caching diff --git a/src/docs/packages.md b/src/docs/packages.md index 6f3157fd3..b1e0cf834 100644 --- a/src/docs/packages.md +++ b/src/docs/packages.md @@ -235,10 +235,12 @@ If your package's service provider should only be loaded in some environments or */ public function isEnabled(): bool { - return (bool) config('courier.enabled'); + return config()->boolean('courier.enabled', false); } ``` +Hypervel calls `isEnabled` before the provider's `register` method, so configuration merged by that provider is not available yet. You may read configuration that the application or framework has already loaded. When an unpublished package option is intentionally optional, as in the example above, provide its fallback here. + ### Class Map Overrides @@ -312,9 +314,17 @@ public function register(): void $this->mergeConfigFrom( __DIR__.'/../config/courier.php', 'courier' ); + + $this->app->singleton(CourierManager::class, function ($app) { + return new CourierManager( + $app->make('config')->array('courier') + ); + }); } ``` +After `mergeConfigFrom` returns, the rest of the provider's `register` method may read the merged package configuration, as shown above. + > [!WARNING] > This method only merges the first level of the configuration array. If your users partially define a multi-dimensional configuration array, the missing options will not be merged. diff --git a/src/docs/providers.md b/src/docs/providers.md index 58a7dc112..af8b58b40 100644 --- a/src/docs/providers.md +++ b/src/docs/providers.md @@ -61,7 +61,7 @@ class RiakServiceProvider extends ServiceProvider public function register(): void { $this->app->singleton(Connection::class, function (Application $app) { - return new Connection(config('riak')); + return new Connection(config()->array('riak')); }); } } @@ -253,10 +253,12 @@ You may prevent a service provider from being registered or booted by overriding */ public function isEnabled(): bool { - return (bool) config('modules.riak.enabled'); + return config()->boolean('modules.riak.enabled', false); } ``` +Hypervel calls `isEnabled` before the provider's `register` method, so configuration merged by that provider is not available yet. You may read configuration that the application or framework has already loaded. When an unpublished package option is intentionally optional, as in the example above, provide its fallback here. + When this method returns `false`, the provider's `register` and `boot` methods will not be called, its `bindings` and `singletons` properties will not be processed, and the provider will not be marked as loaded. diff --git a/src/docs/requests.md b/src/docs/requests.md index 6d6684067..2d01d9031 100644 --- a/src/docs/requests.md +++ b/src/docs/requests.md @@ -953,7 +953,7 @@ If you need to access your application's configuration files or database to dete use Hypervel\Foundation\Configuration\Middleware; ->withMiddleware(function (Middleware $middleware): void { - $middleware->trustHosts(at: fn () => config('app.trusted_hosts')); + $middleware->trustHosts(at: fn () => config()->array('app.trusted_hosts')); }) ``` diff --git a/src/docs/routing.md b/src/docs/routing.md index 60083b8b4..7e617b0a2 100644 --- a/src/docs/routing.md +++ b/src/docs/routing.md @@ -1149,7 +1149,7 @@ If you only need to override a few options, you may merge your changes with the ```php HandleCors::resolveConfigUsing(function (Request $request) { - return array_merge(config('cors'), [ + return array_merge(config()->array('cors'), [ 'allowed_origins' => ['https://' . $request->getHost()], ]); }); diff --git a/src/foundation/resources/exceptions/renderer/components/layout.blade.php b/src/foundation/resources/exceptions/renderer/components/layout.blade.php index cbab0172c..17829144e 100644 --- a/src/foundation/resources/exceptions/renderer/components/layout.blade.php +++ b/src/foundation/resources/exceptions/renderer/components/layout.blade.php @@ -5,7 +5,7 @@ - {{ config('app.name', 'Hypervel') }} + {{ config()->string('app.name') }} {!! Renderer::css() !!} diff --git a/src/foundation/src/Bootstrap/LoadConfiguration.php b/src/foundation/src/Bootstrap/LoadConfiguration.php index 5c9a874ce..eccb165d4 100644 --- a/src/foundation/src/Bootstrap/LoadConfiguration.php +++ b/src/foundation/src/Bootstrap/LoadConfiguration.php @@ -72,11 +72,11 @@ public function bootstrap(Application $app): void // Finally, we will set the application's environment based on the configuration // values that were loaded. We will pass a callback which will be used to get // the environment in a web context where an "--env" switch is not present. - $app->detectEnvironment(fn () => $config->string('app.env', 'production')); + $app->detectEnvironment(fn () => $config->string('app.env')); $app->resolveEnvironmentUsing($app->environment(...)); - date_default_timezone_set($config->get('app.timezone', 'UTC')); + date_default_timezone_set($config->string('app.timezone')); mb_internal_encoding('UTF-8'); } catch (Throwable $exception) { diff --git a/src/foundation/src/Console/AboutCommand.php b/src/foundation/src/Console/AboutCommand.php index 523d0a845..4eec398fb 100644 --- a/src/foundation/src/Console/AboutCommand.php +++ b/src/foundation/src/Console/AboutCommand.php @@ -133,17 +133,17 @@ protected function gatherApplicationInformation(): void $formatStorageLinkedStatus = fn ($value) => $value ? 'LINKED' : 'NOT LINKED'; static::addToSection('Environment', fn () => [ - 'Application Name' => config('app.name'), + 'Application Name' => config()->string('app.name'), 'Hypervel Version' => $this->hypervel->version(), 'PHP Version' => phpversion(), 'Swoole Version' => swoole_version(), 'Composer Version' => $this->composer->getVersion() ?? '-', 'Environment' => $this->hypervel->environment(), - 'Debug Mode' => static::format(config('app.debug'), console: $formatEnabledStatus), + 'Debug Mode' => static::format(config()->boolean('app.debug'), console: $formatEnabledStatus), 'URL' => Str::of(config('app.url'))->replace(['http://', 'https://'], ''), 'Maintenance Mode' => static::format($this->hypervel->isDownForMaintenance(), console: $formatEnabledStatus), - 'Timezone' => config('app.timezone'), - 'Locale' => config('app.locale'), + 'Timezone' => config()->string('app.timezone'), + 'Locale' => config()->string('app.locale'), ]); static::addToSection('Cache', fn () => [ @@ -151,16 +151,16 @@ protected function gatherApplicationInformation(): void 'Events' => static::format($this->hypervel->eventsAreCached(), console: $formatCachedStatus), 'Routes' => static::format($this->hypervel->routesAreCached(), console: $formatCachedStatus), 'AOP Proxies' => static::format($this->hasPhpFiles($this->hypervel->storagePath('framework/aop'), 'cache'), console: $formatCachedStatus), - 'Views' => static::format($this->hasPhpFiles(config('view.compiled')), console: $formatCachedStatus), + 'Views' => static::format($this->hasPhpFiles(config()->string('view.compiled')), console: $formatCachedStatus), ]); static::addToSection('Drivers', fn () => array_filter([ - 'Broadcasting' => config('broadcasting.default'), + 'Broadcasting' => config()->string('broadcasting.default'), 'Cache' => function ($json) { - $cacheStore = config('cache.default'); + $cacheStore = config()->string('cache.default'); - if (config('cache.stores.' . $cacheStore . '.driver') === 'failover') { - $secondary = new Collection(config('cache.stores.' . $cacheStore . '.stores')); + if (config()->string('cache.stores.' . $cacheStore . '.driver') === 'failover') { + $secondary = config()->collection('cache.stores.' . $cacheStore . '.stores'); return value(static::format( value: $cacheStore, @@ -171,12 +171,12 @@ protected function gatherApplicationInformation(): void return $cacheStore; }, - 'Database' => config('database.default'), + 'Database' => config()->string('database.default'), 'Logs' => function ($json) { $logChannel = config('logging.default'); - if (config('logging.channels.' . $logChannel . '.driver') === 'stack') { - $secondary = new Collection(config('logging.channels.' . $logChannel . '.channels')); + if (is_string($logChannel) && config()->string('logging.channels.' . $logChannel . '.driver') === 'stack') { + $secondary = config()->collection('logging.channels.' . $logChannel . '.channels'); return value(static::format( value: $logChannel, @@ -188,10 +188,10 @@ protected function gatherApplicationInformation(): void return $logChannel; }, 'Mail' => function ($json) { - $mailMailer = config('mail.default'); + $mailMailer = config()->string('mail.default'); - if (in_array(config('mail.mailers.' . $mailMailer . '.transport'), ['failover', 'roundrobin'])) { - $secondary = new Collection(config('mail.mailers.' . $mailMailer . '.mailers')); + if (in_array(config()->string('mail.mailers.' . $mailMailer . '.transport'), ['failover', 'roundrobin'], true)) { + $secondary = config()->collection('mail.mailers.' . $mailMailer . '.mailers'); return value(static::format( value: $mailMailer, @@ -203,10 +203,10 @@ protected function gatherApplicationInformation(): void return $mailMailer; }, 'Queue' => function ($json) { - $queueConnection = config('queue.default'); + $queueConnection = config()->string('queue.default'); - if (config('queue.connections.' . $queueConnection . '.driver') === 'failover') { - $secondary = new Collection(config('queue.connections.' . $queueConnection . '.connections')); + if (config()->string('queue.connections.' . $queueConnection . '.driver') === 'failover') { + $secondary = config()->collection('queue.connections.' . $queueConnection . '.connections'); return value(static::format( value: $queueConnection, @@ -218,7 +218,7 @@ protected function gatherApplicationInformation(): void return $queueConnection; }, 'Scout' => config('scout.driver'), - 'Session' => config('session.driver'), + 'Session' => config()->string('session.driver'), ])); static::addToSection('Storage', fn () => [ @@ -235,7 +235,7 @@ protected function gatherApplicationInformation(): void */ protected function determineStoragePathLinkStatus(callable $formatStorageLinkedStatus): array { - return (new Collection(config('filesystems.links', []))) + return config()->collection('filesystems.links') ->mapWithKeys(function ($target, $link) use ($formatStorageLinkedStatus) { $path = Str::replace(public_path(), '', $link); diff --git a/src/foundation/src/Exceptions/Handler.php b/src/foundation/src/Exceptions/Handler.php index 05a2a2f8c..8514576e3 100644 --- a/src/foundation/src/Exceptions/Handler.php +++ b/src/foundation/src/Exceptions/Handler.php @@ -951,7 +951,7 @@ public function shouldRenderJsonWhen(callable $callback): static */ protected function prepareResponse(Request $request, Throwable $e): Response|RedirectResponse { - if (! $this->isHttpException($e) && config('app.debug')) { + if (! $this->isHttpException($e) && config()->boolean('app.debug')) { return $this->toHypervelResponse($this->convertExceptionToResponse($e), $e)->prepare($request); } @@ -983,7 +983,7 @@ protected function convertExceptionToResponse(Throwable $e): SymfonyResponse protected function renderExceptionContent(Throwable $e): string { try { - if (config('app.debug')) { + if (config()->boolean('app.debug')) { if ($this->container->bound(ExceptionRenderer::class)) { return $this->renderExceptionWithCustomRenderer($e); } @@ -992,9 +992,9 @@ protected function renderExceptionContent(Throwable $e): string } } - return $this->renderExceptionWithSymfony($e, config('app.debug')); + return $this->renderExceptionWithSymfony($e, config()->boolean('app.debug')); } catch (Throwable $e) { - return $this->renderExceptionWithSymfony($e, config('app.debug')); + return $this->renderExceptionWithSymfony($e, config()->boolean('app.debug')); } } @@ -1037,7 +1037,7 @@ protected function renderHttpException(HttpExceptionInterface $e): SymfonyRespon $e->getHeaders() ); } catch (Throwable $t) { - config('app.debug') && throw $t; + config()->boolean('app.debug') && throw $t; $this->report($t); } @@ -1114,7 +1114,7 @@ protected function prepareJsonResponse(Request $request, Throwable $e): JsonResp */ protected function convertExceptionToArray(Throwable $e): array { - return config('app.debug') ? [ + return config()->boolean('app.debug') ? [ 'message' => $e->getMessage(), 'exception' => get_class($e), 'file' => $e->getFile(), diff --git a/src/foundation/src/Exceptions/RegisterErrorViewPaths.php b/src/foundation/src/Exceptions/RegisterErrorViewPaths.php index 3ce189d45..543e0e0f9 100644 --- a/src/foundation/src/Exceptions/RegisterErrorViewPaths.php +++ b/src/foundation/src/Exceptions/RegisterErrorViewPaths.php @@ -4,7 +4,6 @@ namespace Hypervel\Foundation\Exceptions; -use Hypervel\Support\Collection; use Hypervel\Support\Facades\View; class RegisterErrorViewPaths @@ -18,7 +17,7 @@ public function __invoke() return; } - View::replaceNamespace('errors', Collection::make(config('view.paths'))->map(function ($path) { + View::replaceNamespace('errors', config()->collection('view.paths')->map(function ($path) { return "{$path}/errors"; })->push(__DIR__ . '/views')->all()); } diff --git a/src/foundation/src/Http/MaintenanceModeBypassCookie.php b/src/foundation/src/Http/MaintenanceModeBypassCookie.php index c0f71e7bd..183b461ac 100644 --- a/src/foundation/src/Http/MaintenanceModeBypassCookie.php +++ b/src/foundation/src/Http/MaintenanceModeBypassCookie.php @@ -19,7 +19,7 @@ public static function create(string $key): Cookie return new Cookie('hypervel_maintenance', base64_encode(json_encode([ 'expires_at' => $expiresAt->getTimestamp(), 'mac' => hash_hmac('sha256', (string) $expiresAt->getTimestamp(), $key), - ])), $expiresAt, config('session.path'), config('session.domain')); + ])), $expiresAt, config()->string('session.path'), config('session.domain')); } /** diff --git a/src/foundation/src/Http/Middleware/PreventRequestForgery.php b/src/foundation/src/Http/Middleware/PreventRequestForgery.php index 21031223f..978df5a3e 100644 --- a/src/foundation/src/Http/Middleware/PreventRequestForgery.php +++ b/src/foundation/src/Http/Middleware/PreventRequestForgery.php @@ -175,7 +175,7 @@ public function shouldAddXsrfTokenCookie(): bool */ protected function addCookieToResponse(Request $request, Response $response): Response { - $config = config('session'); + $config = config()->array('session'); if ($response instanceof Responsable) { $response = $response->toResponse($request); @@ -200,8 +200,8 @@ protected function newCookie(Request $request, array $config): Cookie $config['secure'], false, false, - $config['same_site'] ?? null, - $config['partitioned'] ?? false + $config['same_site'], + $config['partitioned'] ); } diff --git a/src/foundation/src/resources/health-up.blade.php b/src/foundation/src/resources/health-up.blade.php index 4538f5cd2..d5b52a096 100644 --- a/src/foundation/src/resources/health-up.blade.php +++ b/src/foundation/src/resources/health-up.blade.php @@ -4,7 +4,7 @@ - {{ config('app.name', 'Hypervel') }} + {{ config()->string('app.name') }} diff --git a/src/support/src/ServiceProvider.php b/src/support/src/ServiceProvider.php index c4a89e748..4f3f47e30 100644 --- a/src/support/src/ServiceProvider.php +++ b/src/support/src/ServiceProvider.php @@ -241,11 +241,9 @@ protected function loadViewsFrom(array|string $path, string $namespace): void $this->callAfterResolving(ViewFactoryContract::class, function ($view) use ($path, $namespace) { $config = $this->app->make('config'); - if (is_array($viewPaths = $config->get('view.paths'))) { - foreach ($viewPaths as $viewPath) { - if (is_dir($appPath = $viewPath . '/vendor/' . $namespace)) { - $view->addNamespace($namespace, $appPath); - } + foreach ($config->array('view.paths') as $viewPath) { + if (is_dir($appPath = $viewPath . '/vendor/' . $namespace)) { + $view->addNamespace($namespace, $appPath); } } @@ -322,7 +320,7 @@ protected function publishesMigrations(array $paths, mixed $groups = null): void { $this->publishes($paths, $groups); - if ($this->app->make('config')->boolean('database.migrations.update_date_on_publish', false)) { + if ($this->app->make('config')->boolean('database.migrations.update_date_on_publish')) { static::$publishableMigrationPaths = array_unique( array_merge( static::$publishableMigrationPaths, diff --git a/src/testbench/src/Concerns/CreatesApplication.php b/src/testbench/src/Concerns/CreatesApplication.php index 3231dbed1..07d5562b1 100644 --- a/src/testbench/src/Concerns/CreatesApplication.php +++ b/src/testbench/src/Concerns/CreatesApplication.php @@ -117,7 +117,7 @@ protected function getPackageBootstrappers(ApplicationContract $app): array */ protected function getApplicationProviders(ApplicationContract $app): array { - return $app->make('config')->array('app.providers', []); + return $app->make('config')->array('app.providers'); } /** @@ -590,7 +590,7 @@ protected function registerPackageAliases(ApplicationContract $app): void } $config = $app->make('config'); - $existing = $config->array('app.aliases', []); + $existing = $config->array('app.aliases'); $config->set('app.aliases', array_merge($existing, $aliases)); } diff --git a/src/testbench/src/Workbench/Workbench.php b/src/testbench/src/Workbench/Workbench.php index 047ab8f84..702e6306c 100644 --- a/src/testbench/src/Workbench/Workbench.php +++ b/src/testbench/src/Workbench/Workbench.php @@ -189,7 +189,7 @@ public static function discoverRoutes(ApplicationContract $app, ConfigContract $ $app->booted(static function () use ($app, $workbenchViewPath) { tap($app->make('config'), function ($config) use ($workbenchViewPath) { $config->set('view.paths', array_merge( - $config->array('view.paths', []), + $config->array('view.paths'), [$workbenchViewPath] )); }); diff --git a/tests/Foundation/Bootstrap/LoadConfigurationTest.php b/tests/Foundation/Bootstrap/LoadConfigurationTest.php index 80a5b17e4..09fbbd947 100644 --- a/tests/Foundation/Bootstrap/LoadConfigurationTest.php +++ b/tests/Foundation/Bootstrap/LoadConfigurationTest.php @@ -39,16 +39,6 @@ public function testSetsEnvironmentResolver(): void ); } - public function testDontLoadBaseConfiguration(): void - { - $app = new Application; - $app->dontMergeFrameworkConfiguration(); - - (new LoadConfiguration)->bootstrap($app); - - $this->assertNull($app->make('config')->get('app.name')); - } - public function testLoadsConfigurationInIsolation(): void { $app = new Application(__DIR__ . '/../Fixtures'); @@ -117,17 +107,21 @@ public function testBaseConfigurationIncludesCoreFrameworkConfigs(): void public function testDontMergeFrameworkConfigurationSkipsAllBaseConfigs(): void { - $app = new Application; + $app = new Application(__DIR__ . '/../Fixtures'); + $app->useConfigPath(__DIR__ . '/../Fixtures/config'); $app->dontMergeFrameworkConfiguration(); (new LoadConfiguration)->bootstrap($app); - // No base config should be present (app has no config dir with files) $config = $app->make('config'); + $this->assertSame('bar', $config->string('app.foo')); + $this->assertSame('overwrite', $config->string('cache.default')); + $this->assertSame('overwrite', $config->string('database.default')); + $this->assertNull($config->get('app.name')); $this->assertNull($config->get('auth')); - $this->assertNull($config->get('cache')); - $this->assertNull($config->get('database')); + $this->assertNull($config->get('session')); + $this->assertNull($config->get('view')); } public function testAppConfigOverridesBaseConfigValues(): void diff --git a/tests/Foundation/FoundationExceptionsHandlerTest.php b/tests/Foundation/FoundationExceptionsHandlerTest.php index 9eff62a1d..cc15a0118 100644 --- a/tests/Foundation/FoundationExceptionsHandlerTest.php +++ b/tests/Foundation/FoundationExceptionsHandlerTest.php @@ -1261,7 +1261,10 @@ public function testAfterResponseCallbacks() protected function getConfig(array $config = []): Repository { return new Repository(array_merge([ - 'app' => ['url' => 'http://localhost'], + 'app' => [ + 'debug' => false, + 'url' => 'http://localhost', + ], 'rate-limiter' => [ 'default' => 'worker-array', 'stores' => [ diff --git a/tests/Foundation/Listeners/ReloadDotenvAndConfigTest.php b/tests/Foundation/Listeners/ReloadDotenvAndConfigTest.php index 20eab6b5a..b3cd6b2c9 100644 --- a/tests/Foundation/Listeners/ReloadDotenvAndConfigTest.php +++ b/tests/Foundation/Listeners/ReloadDotenvAndConfigTest.php @@ -226,7 +226,6 @@ static function (Repository $config): void { 'app.url' => "https://{$environment}.example.com", 'app.key' => "key-{$environment}", 'app.name' => "Application {$environment}", - 'fortify.passkeys.timeout' => $environment === 'default_value' ? 1000 : 2000, 'sentry.logs_channel_level' => "level-{$environment}", 'logging.channels.sentry' => [ 'driver' => 'custom-sentry', diff --git a/tests/Foundation/StaticStateTest.php b/tests/Foundation/StaticStateTest.php index 250bb5036..787e09391 100644 --- a/tests/Foundation/StaticStateTest.php +++ b/tests/Foundation/StaticStateTest.php @@ -56,7 +56,13 @@ public function buildRecipeCache(): array public function testLoadConfigurationFlushStateClearsAlwaysUseConfig(): void { - LoadConfiguration::alwaysUse(fn () => ['app' => ['name' => 'Static Test']]); + LoadConfiguration::alwaysUse(fn () => [ + 'app' => [ + 'name' => 'Static Test', + 'env' => 'testing', + 'timezone' => 'UTC', + ], + ]); $app = new Application; (new LoadConfiguration)->bootstrap($app); diff --git a/tests/Integration/Foundation/Console/AboutCommandTest.php b/tests/Integration/Foundation/Console/AboutCommandTest.php index 903cf4c76..fe2227c81 100644 --- a/tests/Integration/Foundation/Console/AboutCommandTest.php +++ b/tests/Integration/Foundation/Console/AboutCommandTest.php @@ -4,6 +4,8 @@ namespace Hypervel\Tests\Integration\Foundation\Console; +use Hypervel\Foundation\Console\AboutCommand; +use Hypervel\Support\Facades\Artisan; use Hypervel\Testbench\Attributes\WithEnv; use Hypervel\Testbench\TestCase; use Hypervel\Testing\Assert; @@ -28,6 +30,18 @@ public function testItCanDisplayAboutCommandAsJson() }); } + public function testItDisplaysAnEmptyUrlWhenTheApplicationHasNoCanonicalUrl(): void + { + config(['app.url' => null]); + $this->withoutMockingConsoleOutput(); + + $this->artisan(AboutCommand::class, ['--json' => null]); + + $output = json_decode(Artisan::output(), true); + + $this->assertSame('', $output['environment']['url']); + } + #[WithEnv('VIEW_COMPILED_PATH', __DIR__ . '/Fixtures/compiled-views')] public function testItRespectsCustomPathForCompiledViews() { diff --git a/tests/Integration/Foundation/MaintenanceModeTest.php b/tests/Integration/Foundation/MaintenanceModeTest.php index b3953de3a..96b7ed205 100644 --- a/tests/Integration/Foundation/MaintenanceModeTest.php +++ b/tests/Integration/Foundation/MaintenanceModeTest.php @@ -166,6 +166,15 @@ public function testDownCommandPrerendersTemplateIntoMaintenancePayload() $this->assertFileDoesNotExist(storage_path('framework/maintenance.php')); } + public function testDownCommandReportsARelativeBypassPathWithoutACanonicalApplicationUrl(): void + { + config(['app.url' => null]); + + $this->artisan(DownCommand::class, ['--secret' => 'bypass-secret']) + ->expectsOutputToContain('You may bypass maintenance mode via [/bypass-secret].') + ->assertExitCode(0); + } + public function testMaintenanceModeCanRedirectWithBypassCookie() { file_put_contents(storage_path('framework/down'), json_encode([ diff --git a/tests/Support/SupportServiceProviderTest.php b/tests/Support/SupportServiceProviderTest.php index 691d2f112..5ee19b12d 100644 --- a/tests/Support/SupportServiceProviderTest.php +++ b/tests/Support/SupportServiceProviderTest.php @@ -11,6 +11,7 @@ use Hypervel\Support\ServiceProvider; use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\TestCase; +use InvalidArgumentException; use Mockery as m; use WeakReference; @@ -27,7 +28,10 @@ protected function setUp(): void $this->app = $app = m::mock(Application::class)->makePartial(); $config = new ConfigRepository([ - 'database' => ['migrations' => ['update_date_on_publish' => true]], + 'database' => ['migrations' => [ + 'table' => 'migrations', + 'update_date_on_publish' => true, + ]], ]); $app->shouldReceive('make')->with('config')->andReturn($config)->byDefault(); @@ -175,6 +179,21 @@ public function testPublishesMigrations() $this->assertContains('source/tagged/four', ServiceProvider::publishableMigrationPaths()); } + public function testPublishesMigrationsRejectsAMissingUpdateDateSetting(): void + { + $config = new ConfigRepository([ + 'database' => ['migrations' => ['table' => 'migrations']], + ]); + $this->app->shouldReceive('make')->with('config')->andReturn($config); + $serviceProvider = new ServiceProviderForTestingOne($this->app); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('database.migrations.update_date_on_publish'); + + (fn () => $this->publishesMigrations(['source' => 'destination'])) + ->call($serviceProvider); + } + public function testAllPathsAreReturnedWhenNoFilterIsSpecified() { $allPaths = ServiceProvider::pathsToPublish(); diff --git a/tests/Testbench/Concerns/CreatesApplicationTest.php b/tests/Testbench/Concerns/CreatesApplicationTest.php index eb1be922b..9ada8eee4 100644 --- a/tests/Testbench/Concerns/CreatesApplicationTest.php +++ b/tests/Testbench/Concerns/CreatesApplicationTest.php @@ -53,7 +53,7 @@ public function testRegisterPackageProvidersRegistersProviders(): void public function testRegisterPackageAliasesAddsToConfig(): void { - $aliases = $this->app->make('config')->get('app.aliases', []); + $aliases = $this->app->make('config')->array('app.aliases'); $this->assertArrayHasKey('TestAlias', $aliases); $this->assertSame(TestFacade::class, $aliases['TestAlias']); From 2084b599e7bf1dd6af95344795a6c05a2ade565e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:40:30 +0000 Subject: [PATCH 006/109] foundation: enforce the deprecation logging record Read logging.deprecations through its current array shape in both runtime and Testbench bootstrappers. Require the nullable channel and boolean trace members, resolve named channels through typed configuration, and keep Testbench's deliberate rescue boundary intact. Cover null channels, unknown channels, missing members, and rejection of the legacy scalar shape through the real error-handler path. --- .../src/Bootstrap/HandleExceptions.php | 24 +++++---- .../src/Bootstrap/HandleExceptions.php | 13 ++--- .../Bootstrap/HandleExceptionsTest.php | 52 +++++++++++++++++-- 3 files changed, 67 insertions(+), 22 deletions(-) diff --git a/src/foundation/src/Bootstrap/HandleExceptions.php b/src/foundation/src/Bootstrap/HandleExceptions.php index d1185dbe1..1f6d146e0 100644 --- a/src/foundation/src/Bootstrap/HandleExceptions.php +++ b/src/foundation/src/Bootstrap/HandleExceptions.php @@ -10,6 +10,7 @@ use Hypervel\Contracts\Foundation\Application; use Hypervel\Log\LogManager; use Hypervel\Support\Env; +use InvalidArgumentException; use Monolog\Handler\NullHandler; use PHPUnit\Framework\TestCase; use PHPUnit\Runner\ErrorHandler; @@ -86,10 +87,10 @@ public function handleDeprecationError(string $message, string $file, int $line, $this->ensureDeprecationLoggerIsConfigured(); - $options = static::$app->make('config')->get('logging.deprecations') ?? []; + $trace = static::$app->make('config')->boolean('logging.deprecations.trace'); - with($logger->channel('deprecations'), function ($log) use ($message, $file, $line, $level, $options) { - if ($options['trace'] ?? false) { + with($logger->channel('deprecations'), function ($log) use ($message, $file, $line, $level, $trace) { + if ($trace) { $log->warning((string) new ErrorException($message, 0, $level, $file, $line)); } else { $log->warning(sprintf( @@ -124,15 +125,20 @@ protected function ensureDeprecationLoggerIsConfigured(): void return; } - $this->ensureNullLogDriverIsConfigured(); + $options = $config->array('logging.deprecations'); - if (is_array($options = $config->get('logging.deprecations'))) { - $driver = $options['channel'] ?? 'null'; - } else { - $driver = $options ?? 'null'; + if (! array_key_exists('channel', $options)) { + throw new InvalidArgumentException( + 'Configuration value for key [logging.deprecations.channel] is not defined.' + ); } - $config->set('logging.channels.deprecations', $config->get("logging.channels.{$driver}")); + $this->ensureNullLogDriverIsConfigured(); + + // A declared null channel deliberately selects the null logger. + $driver = $options['channel'] ?? 'null'; + + $config->set('logging.channels.deprecations', $config->array("logging.channels.{$driver}")); } /** diff --git a/src/testbench/src/Bootstrap/HandleExceptions.php b/src/testbench/src/Bootstrap/HandleExceptions.php index 9f079db19..d9ea230ce 100644 --- a/src/testbench/src/Bootstrap/HandleExceptions.php +++ b/src/testbench/src/Bootstrap/HandleExceptions.php @@ -45,16 +45,9 @@ protected function ensureDeprecationLoggerIsConfigured(): void return; } - /** @var null|array{channel?: string, trace?: bool}|string $options */ - $options = $config->get('logging.deprecations'); - $trace = Env::get('LOG_DEPRECATIONS_TRACE', false); - - if (\is_array($options)) { - $driver = $options['channel'] ?? 'null'; - $trace = $options['trace'] ?? true; - } else { - $driver = $options ?? 'null'; - } + $options = $config->array('logging.deprecations'); + $driver = $options['channel'] ?? 'null'; + $trace = $config->boolean('logging.deprecations.trace'); if ($driver === 'single') { $config->set('logging.channels.deprecations', array_merge($config->array('logging.channels.single'), [ diff --git a/tests/Foundation/Bootstrap/HandleExceptionsTest.php b/tests/Foundation/Bootstrap/HandleExceptionsTest.php index 0ead2a95b..8ccaa127f 100644 --- a/tests/Foundation/Bootstrap/HandleExceptionsTest.php +++ b/tests/Foundation/Bootstrap/HandleExceptionsTest.php @@ -15,8 +15,10 @@ use Hypervel\Support\Env; use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\TestCase; +use InvalidArgumentException; use Mockery as m; use Monolog\Handler\NullHandler; +use PHPUnit\Framework\Attributes\DataProvider; use ReflectionClass; use ReflectionMethod; use RuntimeException; @@ -34,7 +36,20 @@ protected function setUp(): void $this->app = m::mock(Application::setInstance(new Application)); - $this->app->instance('config', $this->config = new Config); + $this->app->instance('config', $this->config = new Config([ + 'logging' => [ + 'deprecations' => [ + 'channel' => 'null', + 'trace' => false, + ], + 'channels' => [ + 'null' => [ + 'driver' => 'monolog', + 'handler' => NullHandler::class, + ], + ], + ], + ])); } protected function handleExceptions(): HandleExceptions @@ -189,6 +204,37 @@ public function testNullValueAsChannelUsesNullDriver() ); } + #[DataProvider('invalidDeprecationConfigurationProvider')] + public function testInvalidDeprecationConfigurationFailsLoudly(mixed $configuration, string $key): void + { + $this->app->instance(LogManager::class, m::mock(LogManager::class)); + $this->app->expects('runningUnitTests')->andReturn(false); + $this->app->expects('hasBeenBootstrapped')->andReturn(true); + $this->config->set('logging.deprecations', $configuration); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($key); + + $this->handleExceptions()->handleDeprecationError( + 'Deprecated behavior', + __FILE__, + __LINE__, + ); + } + + /** + * Provide unsupported deprecation configuration shapes. + */ + public static function invalidDeprecationConfigurationProvider(): array + { + return [ + 'legacy scalar' => ['null', 'logging.deprecations'], + 'missing channel' => [['trace' => false], 'logging.deprecations.channel'], + 'unknown channel' => [['channel' => 'missing', 'trace' => false], 'logging.channels.missing'], + 'missing trace' => [['channel' => 'null'], 'logging.deprecations.trace'], + ]; + } + public function testUserDeprecations() { $logger = m::mock(LogManager::class); @@ -280,6 +326,7 @@ public function testEnsuresNullLogDriver() $logger->expects('channel')->with('deprecations')->andReturnSelf(); $logger->expects('warning'); + $this->config->set('logging.channels.null', null); $this->handleExceptions()->handleError( E_USER_DEPRECATED, @@ -322,9 +369,8 @@ public function testDoNotOverrideExistingNullLogDriver() ); } - public function testNoDeprecationsDriverIfNoDeprecationsHereSend() + public function testDoesNotCreateDeprecationsDriverBeforeFirstDeprecation(): void { - $this->assertNull($this->config->get('logging.deprecations')); $this->assertNull($this->config->get('logging.channels.deprecations')); } From 22c84e98d4ed4a9bd5c7ce32a4cb52d568ac2b1c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:40:37 +0000 Subject: [PATCH 007/109] scheduling: use explicit timezone and cache settings Resolve schedule timezone and mutex storage from the shipped nullable configuration instead of duplicating application-timezone and legacy environment fallbacks in the console kernel. Null continues to select the scheduler and cache manager defaults, while a configured store is shared by both mutex implementations. Document SCHEDULE_CACHE_STORE and cover timezone inheritance, default-store selection, and configured mutex stores. --- src/docs/scheduling.md | 8 ++++- src/foundation/src/Console/Kernel.php | 9 ++---- tests/Foundation/Console/KernelTest.php | 42 +++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 8 deletions(-) diff --git a/src/docs/scheduling.md b/src/docs/scheduling.md index b957c2d51..4c0a3ab9f 100644 --- a/src/docs/scheduling.md +++ b/src/docs/scheduling.md @@ -357,6 +357,12 @@ Schedule::command('emails:send')->withoutOverlapping(10); Behind the scenes, the `withoutOverlapping` method utilizes your application's [cache](/docs/{{version}}/cache) to obtain locks. If necessary, you can clear these cache locks using the `schedule:clear-cache` Artisan command. This is typically only necessary if a task becomes stuck due to an unexpected server problem. +By default, scheduling locks use the cache store configured by the `cache.schedule_store` option. This option reads the `SCHEDULE_CACHE_STORE` environment variable and may be set to null to use your application's default cache store: + +```ini +SCHEDULE_CACHE_STORE=database +``` + ### Running Tasks on One Server @@ -376,7 +382,7 @@ Schedule::command('report:generate') ->onOneServer(); ``` -You may use the `useCache` method to customize the cache store used by the scheduler to obtain the atomic locks necessary for single-server tasks: +You may use the `useCache` method to override the configured cache store used by the scheduler to obtain the atomic locks necessary for single-server tasks: ```php Schedule::useCache('database'); diff --git a/src/foundation/src/Console/Kernel.php b/src/foundation/src/Console/Kernel.php index 033743dd8..0db732e05 100644 --- a/src/foundation/src/Console/Kernel.php +++ b/src/foundation/src/Console/Kernel.php @@ -23,7 +23,6 @@ use Hypervel\Support\Arr; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Collection; -use Hypervel\Support\Env; use Hypervel\Support\InteractsWithTime; use Hypervel\Support\Str; use ReflectionClass; @@ -280,9 +279,7 @@ public function resolveConsoleSchedule(): Schedule */ protected function scheduleTimezone(): ?string { - $config = $this->app->make('config'); - - return $config->get('app.schedule_timezone', $config->get('app.timezone')); + return $this->app->make('config')->get('app.schedule_timezone'); } /** @@ -290,9 +287,7 @@ protected function scheduleTimezone(): ?string */ protected function scheduleCache(): ?string { - return $this->app->make('config')->get('cache.schedule_store', Env::get('SCHEDULE_CACHE_DRIVER', function () { - return Env::get('SCHEDULE_CACHE_STORE'); - })); + return $this->app->make('config')->get('cache.schedule_store'); } /** diff --git a/tests/Foundation/Console/KernelTest.php b/tests/Foundation/Console/KernelTest.php index a1bfa5118..a1968384f 100644 --- a/tests/Foundation/Console/KernelTest.php +++ b/tests/Foundation/Console/KernelTest.php @@ -6,13 +6,17 @@ use Hypervel\Console\Application as ConsoleApplication; use Hypervel\Console\Command; +use Hypervel\Console\Scheduling\CacheEventMutex; +use Hypervel\Console\Scheduling\CacheSchedulingMutex; use Hypervel\Contracts\Console\Kernel as KernelContract; use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Events\Dispatcher; use Hypervel\Foundation\Application; use Hypervel\Foundation\Bootstrap\BootProviders; use Hypervel\Foundation\Console\Kernel; use Hypervel\Foundation\Events\Terminating; +use Hypervel\Testbench\Attributes\DefineEnvironment; use Hypervel\Testbench\TestCase; use Mockery as m; use ReflectionMethod; @@ -27,6 +31,14 @@ class KernelTest extends TestCase { + protected function useTokyoApplicationTimezone(ApplicationContract $app): void + { + $app->make('config')->set([ + 'app.timezone' => 'Asia/Tokyo', + 'app.schedule_timezone' => null, + ]); + } + public function testHandleCatchesExceptionsAndReturnsOne() { $handler = m::mock(ExceptionHandlerContract::class); @@ -111,6 +123,36 @@ public function testBootstrapWithoutBootingProvidersSkipsBootProviders() $this->assertNotContains(BootProviders::class, $bootstrappedWith); } + #[DefineEnvironment('useTokyoApplicationTimezone')] + public function testNullScheduleTimezoneUsesTheApplicationTimezone(): void + { + $event = $this->app->make(KernelContract::class) + ->resolveConsoleSchedule() + ->call(static fn (): null => null); + + $this->assertSame('Asia/Tokyo', $event->nextRunDate()->getTimezone()->getName()); + } + + public function testNullScheduleCacheUsesTheDefaultStoreForBothMutexes(): void + { + $this->app->make('config')->set('cache.schedule_store', null); + + $this->app->make(KernelContract::class)->resolveConsoleSchedule(); + + $this->assertNull($this->app->make(CacheEventMutex::class)->store); + $this->assertNull($this->app->make(CacheSchedulingMutex::class)->store); + } + + public function testConfiguredScheduleCacheUsesTheSelectedStoreForBothMutexes(): void + { + $this->app->make('config')->set('cache.schedule_store', 'scheduling'); + + $this->app->make(KernelContract::class)->resolveConsoleSchedule(); + + $this->assertSame('scheduling', $this->app->make(CacheEventMutex::class)->store); + $this->assertSame('scheduling', $this->app->make(CacheSchedulingMutex::class)->store); + } + public function testReportExceptionDelegatesToExceptionHandler() { $exception = new RuntimeException('Test exception'); From e326c73c4440a4c433369405edc5175233dc4168 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:40:49 +0000 Subject: [PATCH 008/109] auth: define guard provider and password broker schemas Make built-in guards, user providers, and password brokers consume their selected record shapes directly while preserving partial public guard-creator defaults. Represent password timeout inheritance, broker selection, remember duration, Eloquent provider caching, and JWT guard TTL states explicitly, with clear errors for incomplete or unknown records. Expand unit and integration coverage for shipped guards, custom providers, cache-backed providers, broker variants, password confirmation, notifications, and remember-me behavior, and update the authentication and password documentation to match. --- src/auth/src/AuthManager.php | 5 +- src/auth/src/AuthServiceProvider.php | 11 +- src/auth/src/CreatesUserProviders.php | 13 +- src/auth/src/Notifications/ResetPassword.php | 2 +- src/auth/src/Notifications/VerifyEmail.php | 2 +- src/auth/src/PasswordConfirmation.php | 8 +- .../src/Passwords/PasswordBrokerManager.php | 38 ++-- src/docs/authentication.md | 18 +- src/docs/passwords.md | 21 +- tests/Auth/AuthManagerTest.php | 199 +++++++++++++++++- .../AuthPasswordBrokerConfigurationTest.php | 52 +++++ tests/Auth/AuthPasswordBrokerManagerTest.php | 128 ++++++++++- tests/Auth/AuthServiceProviderTest.php | 8 +- tests/Auth/PasswordConfirmationTest.php | 42 +++- tests/Auth/RequirePasswordMiddlewareTest.php | 5 + tests/Auth/ResetPasswordNotificationTest.php | 31 ++- tests/Auth/VerifyEmailNotificationTest.php | 10 +- .../Auth/EloquentUserProviderCacheTest.php | 6 + .../EloquentUserProviderCacheTagsTest.php | 3 + .../EloquentUserProviderRedisCacheTest.php | 3 + .../InteractsWithAuthenticationTest.php | 5 + 21 files changed, 551 insertions(+), 59 deletions(-) create mode 100644 tests/Auth/AuthPasswordBrokerConfigurationTest.php diff --git a/src/auth/src/AuthManager.php b/src/auth/src/AuthManager.php index 2ce28267b..7a3c375b9 100755 --- a/src/auth/src/AuthManager.php +++ b/src/auth/src/AuthManager.php @@ -122,7 +122,7 @@ public function createSessionDriver(string $name, array $config): SessionGuard $guard = new SessionGuard( $name, - $this->createUserProvider($config['provider'] ?? null), + $this->createUserProvider($config['provider']), $this->app->make('session.store'), $this->app, rehashOnLogin: $repository->boolean('hashing.rehash_on_login'), @@ -152,9 +152,10 @@ public function createTokenDriver(string $name, array $config): TokenGuard // The token guard implements a basic API token based guard implementation // that takes an API token field from the request and matches it to the // user in the database or another persistence layer where users are. + // Keep the public factory defaults aligned with TokenGuard's constructor. return new TokenGuard( $name, - $this->createUserProvider($config['provider'] ?? null), + $this->createUserProvider($config['provider']), $this->app, $config['input_key'] ?? 'api_token', $config['storage_key'] ?? 'api_token', diff --git a/src/auth/src/AuthServiceProvider.php b/src/auth/src/AuthServiceProvider.php index b41c306ed..d9042773a 100755 --- a/src/auth/src/AuthServiceProvider.php +++ b/src/auth/src/AuthServiceProvider.php @@ -234,9 +234,9 @@ private function cachedEloquentProviders(ConfigRepository $config): array continue; } - $cache = $provider['cache'] ?? null; + $cache = $provider['cache']; - if (! is_array($cache) || empty($cache['enabled'])) { + if (! $cache['enabled']) { continue; } @@ -250,7 +250,7 @@ private function cachedEloquentProviders(ConfigRepository $config): array ); } - $store = $cache['store'] ?? null; + $store = $cache['store']; if (! is_string($store) && $store !== null) { throw new InvalidArgumentException( @@ -258,8 +258,7 @@ private function cachedEloquentProviders(ConfigRepository $config): array ); } - // Keep this fallback aligned with CreatesUserProviders::createEloquentProvider(). - $ttl = $cache['ttl'] ?? 300; + $ttl = $cache['ttl']; if (! is_int($ttl) || $ttl <= 0) { throw new InvalidArgumentException( @@ -267,7 +266,7 @@ private function cachedEloquentProviders(ConfigRepository $config): array ); } - $tags = $cache['tags'] ?? null; + $tags = $cache['tags']; if ($tags !== null && (! is_array($tags) || ! array_all($tags, static fn (mixed $tag): bool => is_string($tag)))) { diff --git a/src/auth/src/CreatesUserProviders.php b/src/auth/src/CreatesUserProviders.php index 67dda8103..8c61966b2 100644 --- a/src/auth/src/CreatesUserProviders.php +++ b/src/auth/src/CreatesUserProviders.php @@ -88,7 +88,7 @@ protected function getProviderConfiguration(?string $provider): ?array protected function createDatabaseProvider(array $config): DatabaseUserProvider { return new DatabaseUserProvider( - $this->app->make('db')->connection($config['connection'] ?? null), + $this->app->make('db')->connection($config['connection']), $this->app->make('hash'), $config['table'], ); @@ -100,19 +100,20 @@ protected function createDatabaseProvider(array $config): DatabaseUserProvider protected function createEloquentProvider(array $config): EloquentUserProvider { $provider = new EloquentUserProvider($this->app->make('hash'), $config['model']); + $cache = $config['cache']; - if (! empty($config['cache']['enabled'])) { - $ttl = $config['cache']['ttl'] ?? 300; + if ($cache['enabled']) { + $ttl = $cache['ttl']; if (! is_int($ttl) || $ttl <= 0) { throw new InvalidArgumentException('The auth user cache TTL must be a positive integer.'); } $provider->enableCache( - $config['cache']['store'] ?? null, + $cache['store'], $ttl, - $config['cache']['prefix'] ?? 'auth_users', - $config['cache']['tags'] ?? null, + $cache['prefix'], + $cache['tags'], ); } diff --git a/src/auth/src/Notifications/ResetPassword.php b/src/auth/src/Notifications/ResetPassword.php index 62a780e9f..24a8a8b9a 100644 --- a/src/auth/src/Notifications/ResetPassword.php +++ b/src/auth/src/Notifications/ResetPassword.php @@ -94,7 +94,7 @@ protected function resolveExpireMinutes(): int ?? Password::getDefaultDriver(); return Container::getInstance()->make(ConfigContract::class) - ->integer("auth.passwords.{$broker}.expire", 60); + ->integer("auth.passwords.{$broker}.expire"); } /** diff --git a/src/auth/src/Notifications/VerifyEmail.php b/src/auth/src/Notifications/VerifyEmail.php index 907d8ecf9..cf98e9793 100644 --- a/src/auth/src/Notifications/VerifyEmail.php +++ b/src/auth/src/Notifications/VerifyEmail.php @@ -73,7 +73,7 @@ protected function verificationUrl(mixed $notifiable): string return URL::temporarySignedRoute( 'verification.verify', - CarbonImmutable::now()->addMinutes(Config::integer('auth.verification.expire', 60)), + CarbonImmutable::now()->addMinutes(Config::integer('auth.verification.expire')), [ 'id' => $notifiable->getKey(), 'hash' => sha1($notifiable->getEmailForVerification()), diff --git a/src/auth/src/PasswordConfirmation.php b/src/auth/src/PasswordConfirmation.php index e9482b937..ad89667d7 100644 --- a/src/auth/src/PasswordConfirmation.php +++ b/src/auth/src/PasswordConfirmation.php @@ -30,10 +30,12 @@ public static function timeout(Repository $config, string $guard, string|int|nul $key = "auth.guards.{$guard}.password_timeout"; - if ($config->has($key)) { - return $config->integer($key); + // A declared null inherits the application-wide timeout. Missing members + // fall through so the typed getter names the incomplete guard record. + if ($config->has($key) && $config->get($key) === null) { + return $config->integer('auth.password_timeout'); } - return $config->integer('auth.password_timeout'); + return $config->integer($key); } } diff --git a/src/auth/src/Passwords/PasswordBrokerManager.php b/src/auth/src/Passwords/PasswordBrokerManager.php index 8dc2c8845..563f6cd2b 100644 --- a/src/auth/src/Passwords/PasswordBrokerManager.php +++ b/src/auth/src/Passwords/PasswordBrokerManager.php @@ -72,7 +72,7 @@ protected function resolve(string $name): PasswordBrokerContract // aggregate service of sorts providing a convenient interface for resets. return new PasswordBroker( $this->createTokenRepository($config), - $this->app->make('auth')->createUserProvider($config['provider'] ?? null), + $this->app->make('auth')->createUserProvider($config['provider']), $name, $this->app->bound('events') ? $this->app->make('events') : null, timeboxDuration: $this->app->make('config')->integer('auth.timebox_duration'), @@ -91,24 +91,26 @@ protected function createTokenRepository(array $config): TokenRepositoryInterfac $key = base64_decode(substr($key, 7)); } - if (isset($config['driver']) && $config['driver'] === 'cache') { - return new CacheTokenRepository( - $this->app->make('cache')->store($config['store'] ?? null), + return match ($config['driver']) { + 'cache' => new CacheTokenRepository( + $this->app->make('cache')->store($config['store']), $this->app->make('hash'), $key, - ($config['expire'] ?? 60) * 60, - $config['throttle'] ?? 0, - ); - } - - return new DatabaseTokenRepository( - $this->app->make('db')->connection($config['connection'] ?? null), - $this->app->make('hash'), - $config['table'], - $key, - ($config['expire'] ?? 60) * 60, - $config['throttle'] ?? 0, - ); + $config['expire'] * 60, + $config['throttle'], + ), + 'database' => new DatabaseTokenRepository( + $this->app->make('db')->connection($config['connection']), + $this->app->make('hash'), + $config['table'], + $key, + $config['expire'] * 60, + $config['throttle'], + ), + default => throw new InvalidArgumentException( + "Password resetter driver [{$config['driver']}] is not defined." + ), + }; } /** @@ -133,7 +135,7 @@ public function resolveBrokerNameForGuard(UnitEnum|string $guard): ?string $config = $this->app->make('config'); $key = "auth.guards.{$guard}.passwords"; - if (! $config->has($key)) { + if ($config->get($key) === null) { return null; } diff --git a/src/docs/authentication.md b/src/docs/authentication.md index 6d98380dd..741a832fd 100644 --- a/src/docs/authentication.md +++ b/src/docs/authentication.md @@ -645,7 +645,7 @@ if (Auth::guard('admin')->attempt($credentials)) { Many web applications provide a "remember me" checkbox on their login form. If you would like to provide "remember me" functionality in your application, you may pass a boolean value as the second argument to the `attempt` method. -When this value is `true`, Hypervel will keep the user authenticated indefinitely or until they manually logout. Your `users` table must include the string `remember_token` column, which will be used to store the "remember me" token. The `users` table migration included with new Hypervel applications already includes this column: +When this value is `true`, Hypervel will keep the user authenticated until the "remember me" cookie expires or they manually log out. By default, the cookie is valid for 400 days. Your `users` table must include the string `remember_token` column, which will be used to store the "remember me" token. The `users` table migration included with new Hypervel applications already includes this column: ```php use Hypervel\Support\Facades\Auth; @@ -655,6 +655,18 @@ if (Auth::attempt(['email' => $email, 'password' => $password], $remember)) { } ``` +You may customize the cookie lifetime for a session guard using the `remember` option in your application's `config/auth.php` configuration file. The value is expressed in minutes. When this option is `null`, Hypervel uses the built-in 400-day lifetime: + +```php +'guards' => [ + 'web' => [ + 'driver' => 'session', + 'provider' => 'users', + 'remember' => null, + ], +], +``` + If your application offers "remember me" functionality, you may use the `viaRemember` method to determine if the currently authenticated user was authenticated using the "remember me" cookie: ```php @@ -679,7 +691,7 @@ use Hypervel\Support\Facades\Auth; Auth::login($user); ``` -You may pass a boolean value as the second argument to the `login` method. This value indicates if "remember me" functionality is desired for the authenticated session. Remember, this means that the session will be authenticated indefinitely or until the user manually logs out of the application: +You may pass a boolean value as the second argument to the `login` method. This value indicates if "remember me" functionality is desired for the authenticated session. The user will remain authenticated until the "remember me" cookie expires or they manually log out of the application: ```php Auth::login($user, $remember = true); @@ -700,7 +712,7 @@ To authenticate a user using their database record's primary key, you may use th Auth::loginUsingId(1); ``` -You may pass a boolean value to the `remember` argument of the `loginUsingId` method. This value indicates if "remember me" functionality is desired for the authenticated session. Remember, this means that the session will be authenticated indefinitely or until the user manually logs out of the application: +You may pass a boolean value to the `remember` argument of the `loginUsingId` method. This value indicates if "remember me" functionality is desired for the authenticated session. The user will remain authenticated until the "remember me" cookie expires or they manually log out of the application: ```php Auth::loginUsingId(1, remember: true); diff --git a/src/docs/passwords.md b/src/docs/passwords.md index 4417486c3..7b8119bf0 100644 --- a/src/docs/passwords.md +++ b/src/docs/passwords.md @@ -38,7 +38,7 @@ Guards that send password reset links declare their password broker with the `pa `Password::setDefaultDriver()` may override the broker for the current coroutine. Otherwise, a bare `Password::sendResetLink()` or `Password::reset()` uses the current guard's `passwords` key. If the current guard does not declare a broker, Hypervel throws a configuration exception naming the guard and the key to add. To target a different broker, pass its name explicitly with `Password::broker('admins')`. -The password reset driver defines where password reset data will be stored. If the `driver` configuration option is omitted, Hypervel will use the `database` driver. Hypervel includes two drivers: +The password reset driver's complete record defines where password reset data will be stored. Hypervel includes two drivers:
@@ -47,6 +47,21 @@ The password reset driver defines where password reset data will be stored. If t
+A database broker requires its driver, provider, table, nullable connection, expiry, and throttle settings. Set `connection` to null to use the default database connection: + +```php +'passwords' => [ + 'users' => [ + 'driver' => 'database', + 'provider' => 'users', + 'table' => 'password_reset_tokens', + 'connection' => null, + 'expire' => 60, + 'throttle' => 60, + ], +], +``` + ### Driver Prerequisites @@ -65,14 +80,14 @@ There is also a cache driver available for handling password resets, which does 'users' => [ 'driver' => 'cache', 'provider' => 'users', - 'store' => 'passwords', // Optional... + 'store' => null, 'expire' => 60, 'throttle' => 60, ], ], ``` -To prevent a call to `artisan cache:clear` from flushing your password reset data, you can optionally specify a separate cache store with the `store` configuration key. The value should correspond to a store configured in your `config/cache.php` configuration value. +Set `store` to null to use the default cache store. To prevent a call to `artisan cache:clear` from flushing your password reset data, specify a separate cache store with the `store` configuration key. The value should correspond to a store configured in your `config/cache.php` configuration value. ### Model Preparation diff --git a/tests/Auth/AuthManagerTest.php b/tests/Auth/AuthManagerTest.php index e5c0dae3d..5d7bb9442 100644 --- a/tests/Auth/AuthManagerTest.php +++ b/tests/Auth/AuthManagerTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Auth; use Closure; +use ErrorException; use Hypervel\Auth\AuthenticationException; use Hypervel\Auth\AuthManager; use Hypervel\Auth\DatabaseUserProvider; @@ -12,6 +13,7 @@ use Hypervel\Auth\Middleware\Authenticate; use Hypervel\Auth\Middleware\RedirectIfAuthenticated; use Hypervel\Auth\RequestGuard; +use Hypervel\Auth\SessionGuard; use Hypervel\Auth\TokenGuard; use Hypervel\Cache\CacheManager; use Hypervel\Cache\ModelCacheStoreValidator; @@ -37,6 +39,7 @@ use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; use ReflectionClass; +use ReflectionProperty; use function Hypervel\Coroutine\parallel; @@ -262,6 +265,68 @@ public function testCreateNullUserProvider() $this->assertNull($manager->createUserProvider('foo')); } + #[DataProvider('guardCreatorProvider')] + public function testBuiltInGuardCreatorsRequireProvider(string $method): void + { + $this->expectException(ErrorException::class); + $this->expectExceptionMessage('Undefined array key "provider"'); + + (new AuthManager($this->app))->{$method}('api', []); + } + + /** + * Provide built-in guard creator methods. + */ + public static function guardCreatorProvider(): array + { + return [ + 'session' => ['createSessionDriver'], + 'token' => ['createTokenDriver'], + ]; + } + + #[DataProvider('defaultRememberConfigurationProvider')] + public function testSessionCreatorKeepsBuiltInRememberDurationWhenNotConfigured(array $config): void + { + $guard = (new AuthManager($this->app))->createSessionDriver('web', $config); + $rememberDuration = new ReflectionProperty(SessionGuard::class, 'rememberDuration'); + + $this->assertSame(576000, $rememberDuration->getValue($guard)); + } + + /** + * Provide configurations that keep the built-in remember duration. + */ + public static function defaultRememberConfigurationProvider(): array + { + return [ + 'omitted' => [['provider' => 'users']], + 'explicit null' => [['provider' => 'users', 'remember' => null]], + ]; + } + + public function testSessionCreatorUsesConfiguredRememberDuration(): void + { + $guard = (new AuthManager($this->app))->createSessionDriver('web', [ + 'provider' => 'users', + 'remember' => 120, + ]); + $rememberDuration = new ReflectionProperty(SessionGuard::class, 'rememberDuration'); + + $this->assertSame(120, $rememberDuration->getValue($guard)); + } + + public function testTokenCreatorKeepsConstructorOwnedDefaultsForPartialRecord(): void + { + $guard = (new AuthManager($this->app))->createTokenDriver('api', [ + 'provider' => 'users', + ]); + + $this->assertSame('api_token', (new ReflectionProperty(TokenGuard::class, 'inputKey'))->getValue($guard)); + $this->assertSame('api_token', (new ReflectionProperty(TokenGuard::class, 'storageKey'))->getValue($guard)); + $this->assertFalse((new ReflectionProperty(TokenGuard::class, 'hash'))->getValue($guard)); + } + public function testCreateDatabaseUserProvider() { $manager = new AuthManager($container = $this->getContainer()); @@ -288,6 +353,24 @@ public function testCreateDatabaseUserProvider() ); } + public function testDatabaseUserProviderRequiresDeclaredConnection(): void + { + $this->app->make('config')->set('auth.providers.incomplete', [ + 'driver' => 'database', + 'table' => 'users', + ]); + $database = m::mock(); + $database->shouldReceive('connection') + ->zeroOrMoreTimes() + ->andReturn(m::mock(ConnectionInterface::class)); + $this->app->instance('db', $database); + + $this->expectException(ErrorException::class); + $this->expectExceptionMessage('Undefined array key "connection"'); + + (new AuthManager($this->app))->createUserProvider('incomplete'); + } + public function testCreateCustomUserProvider() { $manager = new AuthManager($container = $this->getContainer()); @@ -303,6 +386,87 @@ public function testCreateCustomUserProvider() $this->assertSame($provider, $manager->createUserProvider('foo')); } + public function testMissingUserProviderDriverRetainsPurposeBuiltError(): void + { + $this->app->make('config')->set('auth.providers.undefined', []); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Authentication user provider [] is not defined.'); + + (new AuthManager($this->app))->createUserProvider('undefined'); + } + + public function testShippedEloquentUserProviderResolvesWithCacheDisabled(): void + { + $provider = (new AuthManager($this->app))->createUserProvider('users'); + + $this->assertInstanceOf(EloquentUserProvider::class, $provider); + $this->assertFalse($provider->isCacheEnabled()); + } + + public function testCompleteEloquentUserProviderCacheRecordEnablesCaching(): void + { + $this->app->make('config')->set('auth.providers.cached', [ + 'driver' => 'eloquent', + 'model' => AuthManagerCacheUserStub::class, + 'cache' => [ + 'enabled' => true, + 'store' => null, + 'ttl' => 300, + 'prefix' => 'auth_users', + 'tags' => null, + ], + ]); + $repository = m::mock(CacheRepository::class); + $cache = m::mock(CacheManager::class); + $cache->shouldReceive('store')->once()->with(null)->andReturn($repository); + $this->app->instance('cache', $cache); + $validator = m::mock(ModelCacheStoreValidator::class); + $validator->shouldReceive('validate') + ->once() + ->with($repository, 'Auth user cache for model [' . AuthManagerCacheUserStub::class . ']'); + $this->app->instance(ModelCacheStoreValidator::class, $validator); + + $provider = (new AuthManager($this->app))->createUserProvider('cached'); + + $this->assertInstanceOf(EloquentUserProvider::class, $provider); + $this->assertTrue($provider->isCacheEnabled()); + } + + #[DataProvider('incompleteEloquentProviderCacheProvider')] + public function testEloquentUserProviderRequiresCompleteCacheRecord(array $provider, string $member): void + { + $this->app->make('config')->set('auth.providers.incomplete', $provider); + + $this->expectException(ErrorException::class); + $this->expectExceptionMessage("Undefined array key \"{$member}\""); + + (new AuthManager($this->app))->createUserProvider('incomplete'); + } + + /** + * Provide incomplete Eloquent user provider cache records. + */ + public static function incompleteEloquentProviderCacheProvider(): array + { + return [ + 'missing cache block' => [[ + 'driver' => 'eloquent', + 'model' => AuthManagerCacheUserStub::class, + ], 'cache'], + 'missing nullable store' => [[ + 'driver' => 'eloquent', + 'model' => AuthManagerCacheUserStub::class, + 'cache' => [ + 'enabled' => true, + 'ttl' => 300, + 'prefix' => 'auth_users', + 'tags' => null, + ], + ], 'store'], + ]; + } + #[DataProvider('invalidCacheTtlProvider')] public function testCreateEloquentUserProviderRejectsInvalidCacheTtl(mixed $ttl, string $message): void { @@ -313,7 +477,10 @@ public function testCreateEloquentUserProviderRejectsInvalidCacheTtl(mixed $ttl, 'model' => AuthManagerCacheUserStub::class, 'cache' => [ 'enabled' => true, + 'store' => null, 'ttl' => $ttl, + 'prefix' => 'auth_users', + 'tags' => null, ], ], ], @@ -702,12 +869,24 @@ public function testClearUserCacheUsesSpecifiedGuardProvider() 'users' => [ 'driver' => 'eloquent', 'model' => AuthManagerCacheUserStub::class, - 'cache' => ['enabled' => true, 'store' => 'web-store'], + 'cache' => [ + 'enabled' => true, + 'store' => 'web-store', + 'ttl' => 300, + 'prefix' => 'auth_users', + 'tags' => null, + ], ], 'admins' => [ 'driver' => 'eloquent', 'model' => AuthManagerCacheAdminStub::class, - 'cache' => ['enabled' => true, 'store' => 'admin-store', 'prefix' => 'admin_users'], + 'cache' => [ + 'enabled' => true, + 'store' => 'admin-store', + 'ttl' => 300, + 'prefix' => 'admin_users', + 'tags' => null, + ], ], ], ])); @@ -757,7 +936,13 @@ public function testClearUserCacheUsesDefaultGuardAndRespectsResolver() 'users' => [ 'driver' => 'eloquent', 'model' => AuthManagerCacheUserStub::class, - 'cache' => ['enabled' => true, 'store' => 'web-store'], + 'cache' => [ + 'enabled' => true, + 'store' => 'web-store', + 'ttl' => 300, + 'prefix' => 'auth_users', + 'tags' => null, + ], ], ], ])); @@ -795,7 +980,13 @@ public function testForgetGuardsDoesNotAccumulateAuthCacheDescriptors() 'users' => [ 'driver' => 'eloquent', 'model' => AuthManagerCacheUserStub::class, - 'cache' => ['enabled' => true, 'store' => 'redis'], + 'cache' => [ + 'enabled' => true, + 'store' => 'redis', + 'ttl' => 300, + 'prefix' => 'auth_users', + 'tags' => null, + ], ], ], ])); diff --git a/tests/Auth/AuthPasswordBrokerConfigurationTest.php b/tests/Auth/AuthPasswordBrokerConfigurationTest.php new file mode 100644 index 000000000..b21b0c9fe --- /dev/null +++ b/tests/Auth/AuthPasswordBrokerConfigurationTest.php @@ -0,0 +1,52 @@ +app->make('config')->set('auth.passwords.users', $broker); + + $this->expectException(ErrorException::class); + $this->expectExceptionMessage("Undefined array key \"{$member}\""); + + $this->app->make('auth.password')->broker('users'); + } + + /** + * Provide incomplete password broker records. + */ + public static function incompleteBrokerProvider(): array + { + return [ + 'missing common driver' => [[ + 'provider' => 'users', + 'table' => 'password_reset_tokens', + 'connection' => null, + 'expire' => 60, + 'throttle' => 60, + ], 'driver'], + 'missing nullable database connection' => [[ + 'driver' => 'database', + 'provider' => 'users', + 'table' => 'password_reset_tokens', + 'expire' => 60, + 'throttle' => 60, + ], 'connection'], + 'missing nullable cache store' => [[ + 'driver' => 'cache', + 'provider' => 'users', + 'expire' => 60, + 'throttle' => 60, + ], 'store'], + ]; + } +} diff --git a/tests/Auth/AuthPasswordBrokerManagerTest.php b/tests/Auth/AuthPasswordBrokerManagerTest.php index 5e822fe1f..d4ea24ca9 100644 --- a/tests/Auth/AuthPasswordBrokerManagerTest.php +++ b/tests/Auth/AuthPasswordBrokerManagerTest.php @@ -6,6 +6,7 @@ use Hypervel\Auth\Passwords\PasswordBroker as PasswordBrokerImplementation; use Hypervel\Auth\Passwords\PasswordBrokerManager; +use Hypervel\Cache\Repository as CacheRepository; use Hypervel\Config\Repository; use Hypervel\Container\Container; use Hypervel\Contracts\Auth\Factory as AuthFactory; @@ -51,6 +52,21 @@ public function testResolveBrokerNameForGuardReturnsNullWhenAbsent(): void $this->assertNull($manager->resolveBrokerNameForGuard('staff')); } + public function testResolveBrokerNameForGuardReturnsNullWhenExplicitlyNull(): void + { + $manager = new PasswordBrokerManager($this->makeContainer([ + 'auth' => [ + 'guards' => [ + 'staff' => [ + 'passwords' => null, + ], + ], + ], + ])); + + $this->assertNull($manager->resolveBrokerNameForGuard('staff')); + } + public function testResolveBrokerNameForGuardReturnsNullForEmptyString(): void { $manager = new PasswordBrokerManager($this->makeContainer([ @@ -91,7 +107,6 @@ public function testResolveBrokerNameForGuardFailsFastOnMalformedValues(mixed $b public static function malformedPasswordBrokerProvider(): array { return [ - 'null' => [null], 'integer' => [123], 'array' => [['users']], ]; @@ -113,6 +128,16 @@ public function testDefaultDriverResolvesFromCurrentDefaultGuard(): void $this->assertSame('users', (new PasswordBrokerManager($container))->getDefaultDriver()); } + public function testDefaultDriverResolvesFromShippedWebGuard(): void + { + $container = $this->makeContainer([ + 'auth' => require __DIR__ . '/../../src/foundation/config/auth.php', + ]); + $container->instance(AuthFactory::class, $this->mockAuthFactory('web')); + + $this->assertSame('users', (new PasswordBrokerManager($container))->getDefaultDriver()); + } + public function testDefaultDriverAcceptsFalseyBrokerNames(): void { $container = $this->makeContainer([ @@ -246,8 +271,12 @@ public function testBrokerWithExplicitNameBypassesDefaultResolution(): void 'timebox_duration' => 200000, 'passwords' => [ 'admins' => [ + 'driver' => 'database', 'provider' => 'admins', 'table' => 'admin_password_reset_tokens', + 'connection' => null, + 'expire' => 60, + 'throttle' => 60, ], ], ], @@ -278,8 +307,12 @@ public function testBrokerWithExplicitFalseyNameDoesNotFallBackToDefaultDriver() 'timebox_duration' => 200000, 'passwords' => [ '0' => [ + 'driver' => 'database', 'provider' => 'zero', 'table' => 'zero_password_reset_tokens', + 'connection' => null, + 'expire' => 60, + 'throttle' => 60, ], ], ], @@ -300,6 +333,95 @@ public function testBrokerWithExplicitFalseyNameDoesNotFallBackToDefaultDriver() $this->assertInstanceOf(PasswordBrokerContract::class, (new PasswordBrokerManager($container))->broker('0')); } + public function testShippedDatabaseBrokerUsesDefaultConnection(): void + { + $container = $this->makeContainer([ + 'app' => [ + 'key' => 'base64:' . base64_encode(str_repeat('a', 32)), + ], + 'auth' => require __DIR__ . '/../../src/foundation/config/auth.php', + ]); + $container->instance('auth', $auth = m::mock()); + $container->instance('db', $database = m::mock()); + $container->instance('hash', m::mock(Hasher::class)); + + $auth->shouldReceive('createUserProvider') + ->once() + ->with('users') + ->andReturn(m::mock(UserProvider::class)); + $database->shouldReceive('connection') + ->once() + ->with(null) + ->andReturn(m::mock(ConnectionInterface::class)); + + $this->assertInstanceOf( + PasswordBrokerContract::class, + (new PasswordBrokerManager($container))->broker('users') + ); + } + + public function testCacheBrokerUsesDefaultStore(): void + { + $container = $this->makeContainer([ + 'app' => [ + 'key' => 'base64:' . base64_encode(str_repeat('a', 32)), + ], + 'auth' => [ + 'timebox_duration' => 200000, + 'passwords' => [ + 'users' => [ + 'driver' => 'cache', + 'provider' => 'users', + 'store' => null, + 'expire' => 60, + 'throttle' => 60, + ], + ], + ], + ]); + $container->instance('auth', $auth = m::mock()); + $container->instance('cache', $cache = m::mock()); + $container->instance('hash', m::mock(Hasher::class)); + + $auth->shouldReceive('createUserProvider') + ->once() + ->with('users') + ->andReturn(m::mock(UserProvider::class)); + $cache->shouldReceive('store') + ->once() + ->with(null) + ->andReturn(m::mock(CacheRepository::class)); + + $this->assertInstanceOf( + PasswordBrokerContract::class, + (new PasswordBrokerManager($container))->broker('users') + ); + } + + public function testBrokerRejectsUnknownDriver(): void + { + $container = $this->makeContainer([ + 'app' => [ + 'key' => 'base64:' . base64_encode(str_repeat('a', 32)), + ], + 'auth' => [ + 'passwords' => [ + 'users' => [ + 'driver' => 'unknown', + 'provider' => 'users', + 'expire' => 60, + 'throttle' => 60, + ], + ], + ], + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Password resetter driver [unknown] is not defined.'); + + (new PasswordBrokerManager($container))->broker('users'); + } + public function testBrokerNormalizesEnumsBeforeCaching(): void { $broker = m::mock(PasswordBrokerContract::class); @@ -355,8 +477,12 @@ public function testBrokerFailsFastWhenAppKeyIsNotConfigured(): void 'auth' => [ 'passwords' => [ 'users' => [ + 'driver' => 'database', 'provider' => 'users', 'table' => 'password_reset_tokens', + 'connection' => null, + 'expire' => 60, + 'throttle' => 60, ], ], ], diff --git a/tests/Auth/AuthServiceProviderTest.php b/tests/Auth/AuthServiceProviderTest.php index 7108ae662..05ef7afac 100644 --- a/tests/Auth/AuthServiceProviderTest.php +++ b/tests/Auth/AuthServiceProviderTest.php @@ -57,8 +57,8 @@ public function testBootContributesEnabledConfiguredEloquentModelsAndFrameworkCo 'disabled' => $this->cachedProvider(AuthProviderUser::class, enabled: false), 'database' => [ 'driver' => 'database', - 'model' => InvalidAuthProviderModel::class, - 'cache' => ['enabled' => true], + 'table' => 'users', + 'connection' => null, ], 'malformed', ], @@ -150,7 +150,6 @@ public function testDisabledProvidersResolveNeitherAStoreNorTheValidator(): void 'users' => $this->cachedProvider(AuthProviderUser::class, enabled: false), 'custom' => [ 'driver' => 'custom', - 'cache' => ['enabled' => true], ], ], ], @@ -415,6 +414,9 @@ private function cachedProvider( 'cache' => [ 'enabled' => $enabled, 'store' => $store, + 'ttl' => 300, + 'prefix' => 'auth_users', + 'tags' => null, ], ]; } diff --git a/tests/Auth/PasswordConfirmationTest.php b/tests/Auth/PasswordConfirmationTest.php index fa24e462f..55c9d209d 100644 --- a/tests/Auth/PasswordConfirmationTest.php +++ b/tests/Auth/PasswordConfirmationTest.php @@ -31,25 +31,61 @@ public function testTimeoutUsesGuardDeclaration(): void $this->assertSame(900, PasswordConfirmation::timeout($config, 'admin')); } - public function testTimeoutFallsBackToGlobal(): void + public function testExplicitNullTimeoutInheritsGlobal(): void { $config = new Repository([ 'auth' => [ 'password_timeout' => 3600, + 'guards' => [ + 'admin' => [ + 'password_timeout' => null, + ], + ], ], ]); $this->assertSame(3600, PasswordConfirmation::timeout($config, 'admin')); } - public function testTimeoutFailsWhenShippedGlobalSettingIsMissing(): void + public function testTimeoutFailsWhenGuardSettingIsMissing(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Configuration value for key [auth.password_timeout] must be an integer, NULL given.'); + $this->expectExceptionMessage( + 'Configuration value for key [auth.guards.admin.password_timeout] must be an integer, NULL given.' + ); PasswordConfirmation::timeout(new Repository, 'admin'); } + public function testTimeoutFailsWhenGlobalSettingIsMissing(): void + { + $config = new Repository([ + 'auth' => [ + 'guards' => [ + 'admin' => [ + 'password_timeout' => null, + ], + ], + ], + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Configuration value for key [auth.password_timeout] must be an integer, NULL given.'); + + PasswordConfirmation::timeout($config, 'admin'); + } + + public function testShippedWebGuardInheritsGlobalTimeout(): void + { + $auth = require __DIR__ . '/../../src/foundation/config/auth.php'; + $config = new Repository(['auth' => $auth]); + + $this->assertSame( + $config->integer('auth.password_timeout'), + PasswordConfirmation::timeout($config, 'web') + ); + } + public function testTimeoutFailsFastOnMalformedGuardValue(): void { $config = new Repository([ diff --git a/tests/Auth/RequirePasswordMiddlewareTest.php b/tests/Auth/RequirePasswordMiddlewareTest.php index eb11082d6..891461bd2 100644 --- a/tests/Auth/RequirePasswordMiddlewareTest.php +++ b/tests/Auth/RequirePasswordMiddlewareTest.php @@ -401,6 +401,11 @@ private function middleware( $config ?? new Repository([ 'auth' => [ 'password_timeout' => 10800, + 'guards' => [ + $guard => [ + 'password_timeout' => null, + ], + ], ], ]), ); diff --git a/tests/Auth/ResetPasswordNotificationTest.php b/tests/Auth/ResetPasswordNotificationTest.php index 30c82e573..a61a1e420 100644 --- a/tests/Auth/ResetPasswordNotificationTest.php +++ b/tests/Auth/ResetPasswordNotificationTest.php @@ -9,8 +9,16 @@ use Hypervel\Context\CoroutineContext; use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Testbench\TestCase; - -#[WithConfig('auth.passwords.admins', ['provider' => 'users', 'table' => 'admin_password_reset_tokens', 'expire' => 15])] +use InvalidArgumentException; + +#[WithConfig('auth.passwords.admins', [ + 'driver' => 'database', + 'provider' => 'users', + 'table' => 'admin_password_reset_tokens', + 'connection' => null, + 'expire' => 15, + 'throttle' => 60, +])] class ResetPasswordNotificationTest extends TestCase { public function testExpiryIsCapturedFromSendingBrokerContext(): void @@ -55,6 +63,25 @@ public function testExpirySurvivesSerialization(): void ); } + public function testExpiryFailsWhenBrokerSettingIsOmitted(): void + { + config()->set('auth.passwords.admins', [ + 'driver' => 'database', + 'provider' => 'users', + 'table' => 'admin_password_reset_tokens', + 'connection' => null, + 'throttle' => 60, + ]); + CoroutineContext::set(PasswordBroker::SENDING_BROKER_CONTEXT_KEY, 'admins'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Configuration value for key [auth.passwords.admins.expire] must be an integer, NULL given.' + ); + + new ResetPassword('token'); + } + /** * Get the notification mail lines. */ diff --git a/tests/Auth/VerifyEmailNotificationTest.php b/tests/Auth/VerifyEmailNotificationTest.php index a54582a79..8d82269f5 100644 --- a/tests/Auth/VerifyEmailNotificationTest.php +++ b/tests/Auth/VerifyEmailNotificationTest.php @@ -8,6 +8,7 @@ use Hypervel\Routing\Router; use Hypervel\Support\CarbonImmutable; use Hypervel\Testbench\TestCase; +use InvalidArgumentException; use Override; class VerifyEmailNotificationTest extends TestCase @@ -29,14 +30,17 @@ public function testVerificationUrlUsesConfiguredExpiry(): void $this->assertSame(now()->addMinutes(90)->getTimestamp(), $this->expiresAt($url)); } - public function testVerificationUrlUsesFallbackWhenNestedSettingIsOmitted(): void + public function testVerificationUrlFailsWhenExpirySettingIsOmitted(): void { CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 8, 5, 12)); config(['auth.verification' => []]); - $url = (new VerifyEmailNotificationStub)->verificationUrlFor(new VerifyEmailNotifiableStub); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Configuration value for key [auth.verification.expire] must be an integer, NULL given.' + ); - $this->assertSame(now()->addMinutes(60)->getTimestamp(), $this->expiresAt($url)); + (new VerifyEmailNotificationStub)->verificationUrlFor(new VerifyEmailNotifiableStub); } public function testMailMessageUsesTranslatedStringMetadata(): void diff --git a/tests/Integration/Auth/EloquentUserProviderCacheTest.php b/tests/Integration/Auth/EloquentUserProviderCacheTest.php index b74be3f32..8957118de 100644 --- a/tests/Integration/Auth/EloquentUserProviderCacheTest.php +++ b/tests/Integration/Auth/EloquentUserProviderCacheTest.php @@ -88,6 +88,9 @@ protected function defineEnvironment(ApplicationContract $app): void 'cache' => [ 'enabled' => true, 'store' => 'auth-file', + 'ttl' => 300, + 'prefix' => 'auth_users', + 'tags' => null, ], ], 'auth.providers.relationship_users' => [ @@ -96,6 +99,9 @@ protected function defineEnvironment(ApplicationContract $app): void 'cache' => [ 'enabled' => true, 'store' => 'auth-file', + 'ttl' => 300, + 'prefix' => 'auth_users', + 'tags' => null, ], ], 'database.connections.auth_secondary' => [ diff --git a/tests/Integration/Auth/Redis/EloquentUserProviderCacheTagsTest.php b/tests/Integration/Auth/Redis/EloquentUserProviderCacheTagsTest.php index 0e8a8e35e..92971e350 100644 --- a/tests/Integration/Auth/Redis/EloquentUserProviderCacheTagsTest.php +++ b/tests/Integration/Auth/Redis/EloquentUserProviderCacheTagsTest.php @@ -57,6 +57,9 @@ protected function defineEnvironment(ApplicationContract $app): void 'cache' => [ 'enabled' => true, 'store' => self::STORE_NAME, + 'ttl' => 300, + 'prefix' => 'auth_users', + 'tags' => null, ], ], ]); diff --git a/tests/Integration/Auth/Redis/EloquentUserProviderRedisCacheTest.php b/tests/Integration/Auth/Redis/EloquentUserProviderRedisCacheTest.php index 66346e811..d1553fb74 100644 --- a/tests/Integration/Auth/Redis/EloquentUserProviderRedisCacheTest.php +++ b/tests/Integration/Auth/Redis/EloquentUserProviderRedisCacheTest.php @@ -76,6 +76,9 @@ protected function defineEnvironment(ApplicationContract $app): void 'cache' => [ 'enabled' => true, 'store' => 'auth-redis-none', + 'ttl' => 300, + 'prefix' => 'auth_users', + 'tags' => null, ], ], ]); diff --git a/tests/Integration/Foundation/Testing/Concerns/InteractsWithAuthenticationTest.php b/tests/Integration/Foundation/Testing/Concerns/InteractsWithAuthenticationTest.php index cf190767a..c221dc2d8 100644 --- a/tests/Integration/Foundation/Testing/Concerns/InteractsWithAuthenticationTest.php +++ b/tests/Integration/Foundation/Testing/Concerns/InteractsWithAuthenticationTest.php @@ -30,6 +30,8 @@ protected function defineEnvironment(ApplicationContract $app): void $app->make('config')->set('auth.guards.api', [ 'driver' => 'token', 'provider' => 'users', + 'passwords' => null, + 'password_timeout' => null, 'hash' => false, ]); } @@ -204,6 +206,9 @@ public function testRequestDrivenLogoutOnlyClearsTheLoggedOutGuard(): void ->set('auth.guards.secondary', [ 'driver' => 'session', 'provider' => 'users', + 'passwords' => null, + 'password_timeout' => null, + 'remember' => null, ]); Route::post('logout-web', function (Request $request) { From ac8be1bf9022f628b2750a35031d9cd3cef25538 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:40:59 +0000 Subject: [PATCH 009/109] database: remove hidden connection and migration defaults Use typed configuration for default connections, inspection commands, Faker locale, and the current database.migrations array shape. Remove scalar migration-table normalization while preserving the database command's purpose-built invalid-connection error for dynamic names. Update truncation, connector, monitor, migration publishing, and command coverage so current records succeed and incomplete or legacy shapes fail at their owning boundary. --- src/database/src/ConnectionResolver.php | 2 +- .../src/Console/DatabaseInspectionCommand.php | 4 +-- src/database/src/Console/DbCommand.php | 2 +- src/database/src/Console/DumpCommand.php | 4 +-- .../src/Console/Migrations/MigrateCommand.php | 2 +- src/database/src/Console/MonitorCommand.php | 2 +- src/database/src/DatabaseServiceProvider.php | 10 ++----- .../src/Testing/DatabaseTruncation.php | 6 ++-- tests/Database/DatabaseConnectorTest.php | 3 +- tests/Database/DatabaseDbCommandTest.php | 9 ++++++ tests/Database/DatabaseMonitorCommandTest.php | 12 ++++---- .../Database/DatabaseServiceProviderTest.php | 30 +++++++++++++++++++ .../Testing/DatabaseTruncationTest.php | 1 + 13 files changed, 59 insertions(+), 28 deletions(-) diff --git a/src/database/src/ConnectionResolver.php b/src/database/src/ConnectionResolver.php index 48211a554..e4d154de7 100755 --- a/src/database/src/ConnectionResolver.php +++ b/src/database/src/ConnectionResolver.php @@ -52,7 +52,7 @@ public function __construct( protected Container $container ) { $this->factory = $container->make(PoolFactory::class); - $this->default = $container->make('config')->string('database.default', 'default'); + $this->default = $container->make('config')->string('database.default'); } /** diff --git a/src/database/src/Console/DatabaseInspectionCommand.php b/src/database/src/Console/DatabaseInspectionCommand.php index dcfce7ecb..625c92a3a 100644 --- a/src/database/src/Console/DatabaseInspectionCommand.php +++ b/src/database/src/Console/DatabaseInspectionCommand.php @@ -17,8 +17,8 @@ abstract class DatabaseInspectionCommand extends Command */ protected function getConfigFromDatabase(?string $database): array { - $database ??= config('database.default'); + $database ??= config()->string('database.default'); - return Arr::except(config('database.connections.' . $database), ['password']); + return Arr::except(config()->array('database.connections.' . $database), ['password']); } } diff --git a/src/database/src/Console/DbCommand.php b/src/database/src/Console/DbCommand.php index 7e49eaa6f..4ec6cd367 100644 --- a/src/database/src/Console/DbCommand.php +++ b/src/database/src/Console/DbCommand.php @@ -69,7 +69,7 @@ public function handle(): int public function getConnection(): array { $config = $this->hypervel->make('config'); - $connectionName = $this->argument('connection') ?? $config->string('database.default', 'default'); + $connectionName = $this->argument('connection') ?? $config->string('database.default'); $connection = $config->array("database.connections.{$connectionName}", []); if (empty($connection)) { diff --git a/src/database/src/Console/DumpCommand.php b/src/database/src/Console/DumpCommand.php index 3393f0608..a6d6dacf2 100644 --- a/src/database/src/Console/DumpCommand.php +++ b/src/database/src/Console/DumpCommand.php @@ -76,9 +76,7 @@ public function handle(ConnectionResolverInterface $connections, Dispatcher $dis */ protected function schemaState(Connection $connection): mixed { - $migrations = Config::get('database.migrations', 'migrations'); - - $migrationTable = is_array($migrations) ? ($migrations['table'] ?? 'migrations') : $migrations; + $migrationTable = Config::string('database.migrations.table'); if ($this->option('without-migration-data')) { $migrationTable = null; diff --git a/src/database/src/Console/Migrations/MigrateCommand.php b/src/database/src/Console/Migrations/MigrateCommand.php index 6d7476987..995ea5b10 100644 --- a/src/database/src/Console/Migrations/MigrateCommand.php +++ b/src/database/src/Console/Migrations/MigrateCommand.php @@ -222,7 +222,7 @@ protected function createMissingSqliteDatabase(string $path): bool protected function createMissingMySqlOrPgsqlDatabase(Connection $connection): bool { $adminConfig = (new ConfigurationUrlParser)->parseConfiguration( - $this->hypervel->make('config')->get("database.connections.{$connection->getName()}") + $this->hypervel->make('config')->array("database.connections.{$connection->getName()}") ); if (($adminConfig['database'] ?? null) !== $connection->getDatabaseName()) { diff --git a/src/database/src/Console/MonitorCommand.php b/src/database/src/Console/MonitorCommand.php index d754bf44e..256ec4cd6 100644 --- a/src/database/src/Console/MonitorCommand.php +++ b/src/database/src/Console/MonitorCommand.php @@ -67,7 +67,7 @@ protected function parseDatabases(?string $databases): Collection { return (new Collection(explode(',', $databases ?? '')))->map(function ($database) { if ($database === '') { - $database = $this->hypervel->make('config')->string('database.default', 'default'); + $database = $this->hypervel->make('config')->string('database.default'); } $maxConnections = $this->option('max'); diff --git a/src/database/src/DatabaseServiceProvider.php b/src/database/src/DatabaseServiceProvider.php index 8d2acd98b..ff5fd6b16 100644 --- a/src/database/src/DatabaseServiceProvider.php +++ b/src/database/src/DatabaseServiceProvider.php @@ -57,15 +57,9 @@ public function register(): void $this->app->singleton('db.resolver', fn ($app) => $app->make(ConnectionResolver::class)); $this->app->singleton('migration.repository', function ($app) { - $migrations = $app->make('config')->get('database.migrations'); - - $table = is_array($migrations) - ? ($migrations['table'] ?? 'migrations') - : $migrations; - return new DatabaseMigrationRepository( $app->make('db'), - $table, + $app->make('config')->string('database.migrations.table'), ); }); @@ -174,7 +168,7 @@ protected function registerFakerGenerator(): void } $this->app->scoped(FakerGenerator::class, function ($app, $parameters) { - $locale = $parameters['locale'] ?? $app->make('config')->get('app.faker_locale', 'en_US'); + $locale = $parameters['locale'] ?? $app->make('config')->string('app.faker_locale'); return FakerFactory::create($locale); }); diff --git a/src/foundation/src/Testing/DatabaseTruncation.php b/src/foundation/src/Testing/DatabaseTruncation.php index 8e9a93bdb..c8ce9a8a0 100644 --- a/src/foundation/src/Testing/DatabaseTruncation.php +++ b/src/foundation/src/Testing/DatabaseTruncation.php @@ -151,10 +151,8 @@ protected function tablesToTruncate(ConnectionInterface $connection, ?string $co */ protected function exceptTables(ConnectionInterface $connection, ?string $connectionName): array { - $migrations = $this->app->make('config')->get('database.migrations'); - - $migrationsTable = is_array($migrations) ? ($migrations['table'] ?? 'migrations') : $migrations; - $migrationsTable = $connection->getTablePrefix() . $migrationsTable; + $migrationsTable = $connection->getTablePrefix() + . $this->app->make('config')->string('database.migrations.table'); return property_exists($this, 'exceptTables') && is_array($this->exceptTables) ? array_merge( diff --git a/tests/Database/DatabaseConnectorTest.php b/tests/Database/DatabaseConnectorTest.php index af2058679..7f77eccab 100755 --- a/tests/Database/DatabaseConnectorTest.php +++ b/tests/Database/DatabaseConnectorTest.php @@ -137,7 +137,8 @@ public function testPostgresLockTimeoutIsBakedIntoDsn(): void public function testPostgresRejectsInvalidLockTimeout(): void { $config = ['host' => 'foo', 'database' => 'bar', 'lock_timeout' => '2']; - $connector = $this->getMockBuilder(PostgresConnector::class)->onlyMethods(['createConnection', 'getOptions'])->getMock(); + $connector = $this->getMockBuilder(PostgresConnector::class)->onlyMethods(['createConnection'])->getMock(); + $connector->expects($this->never())->method('createConnection'); $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Database connection [lock_timeout] must be a positive integer.'); diff --git a/tests/Database/DatabaseDbCommandTest.php b/tests/Database/DatabaseDbCommandTest.php index 9fb4776d8..cdbce2d9a 100644 --- a/tests/Database/DatabaseDbCommandTest.php +++ b/tests/Database/DatabaseDbCommandTest.php @@ -11,6 +11,7 @@ use Mockery as m; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputInterface; +use UnexpectedValueException; class DatabaseDbCommandTest extends TestCase { @@ -106,6 +107,14 @@ public function testDefaultConnectionIsReadFromConfigRepository(): void $this->assertSame('write-host', $connection['host']); } + public function testUnknownConnectionUsesTheCommandSpecificError(): void + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Invalid database connection [missing].'); + + $this->getConnection([], ['connection' => 'missing']); + } + public function testUrlConfigIsParsedBeforeReadWriteMerge(): void { $connection = $this->getConnection([ diff --git a/tests/Database/DatabaseMonitorCommandTest.php b/tests/Database/DatabaseMonitorCommandTest.php index 413710ca5..48fe9c2de 100644 --- a/tests/Database/DatabaseMonitorCommandTest.php +++ b/tests/Database/DatabaseMonitorCommandTest.php @@ -10,26 +10,26 @@ use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Database\Console\MonitorCommand; use Hypervel\Testbench\TestCase; +use InvalidArgumentException; use Mockery as m; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\NullOutput; class DatabaseMonitorCommandTest extends TestCase { - public function testMonitorCommandFallsBackToDefaultConnectionNameWhenDatabaseDefaultIsMissing(): void + public function testMonitorCommandFailsWhenDatabaseDefaultIsMissing(): void { $this->app->instance('config', new Repository(['database' => []])); - $connection = m::mock(ConnectionInterface::class); - $connection->shouldReceive('threadCount')->once()->andReturn(1); - $resolver = m::mock(ConnectionResolverInterface::class); - $resolver->shouldReceive('connection')->once()->with('default')->andReturn($connection); $command = new MonitorCommand($resolver, m::mock(Dispatcher::class)); $command->setHypervel($this->app); - $this->assertSame(0, $command->run(new ArrayInput([]), new NullOutput)); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Configuration value for key [database.default] must be a string, NULL given.'); + + $command->run(new ArrayInput([]), new NullOutput); } public function testMonitorCommandPreservesConnectionNameZero(): void diff --git a/tests/Database/DatabaseServiceProviderTest.php b/tests/Database/DatabaseServiceProviderTest.php index 2a4f272f9..68d1b0a82 100644 --- a/tests/Database/DatabaseServiceProviderTest.php +++ b/tests/Database/DatabaseServiceProviderTest.php @@ -20,6 +20,7 @@ use Hypervel\Database\LostConnectionDetector; use Hypervel\Events\Dispatcher; use Hypervel\Testbench\TestCase; +use InvalidArgumentException; use PDOException; use RuntimeException; use Swoole\Constant; @@ -27,6 +28,35 @@ class DatabaseServiceProviderTest extends TestCase { + public function testMigrationRepositoryUsesTheCurrentArrayConfiguration(): void + { + config(['database.migrations' => [ + 'table' => 'custom_migrations', + 'update_date_on_publish' => true, + ]]); + $this->app->forgetInstance('migration.repository'); + + $repository = $this->app->make('migration.repository'); + $repository->createRepository(); + + try { + $this->assertTrue($repository->repositoryExists()); + } finally { + $repository->deleteRepository(); + } + } + + public function testMigrationRepositoryRejectsTheLegacyScalarConfiguration(): void + { + config(['database.migrations' => 'legacy_migrations']); + $this->app->forgetInstance('migration.repository'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('database.migrations.table'); + + $this->app->make('migration.repository'); + } + public function testReloadConfigurationRebuildsTheConnectionResolverFromCurrentConfiguration(): void { config(['database.default' => 'first']); diff --git a/tests/Foundation/Testing/DatabaseTruncationTest.php b/tests/Foundation/Testing/DatabaseTruncationTest.php index 9211f423a..790657717 100644 --- a/tests/Foundation/Testing/DatabaseTruncationTest.php +++ b/tests/Foundation/Testing/DatabaseTruncationTest.php @@ -34,6 +34,7 @@ protected function setUp(): void 'database' => [ 'migrations' => [ 'table' => 'migrations', + 'update_date_on_publish' => true, ], ], ])); From 463a2a92c53883e9f08e328cd1e78e36dab6aafa Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:41:08 +0000 Subject: [PATCH 010/109] redis: require complete topology-specific records Replace the connection class's recursively merged hidden schema with validated standalone, Sentinel, or Cluster records assembled by RedisConfig. Centralize timeout, prefix, event, pool, Sentinel, and PhpRedis option handling while keeping topology discriminators and open option bags explicit. Align the parallel integration harness with real Cluster records, preserve null timeout and prefix inheritance, cover event overrides and pool behavior, and document complete examples for each supported topology. --- src/docs/redis.md | 158 +++++----- .../src/Testing/RedisTestConfiguration.php | 14 +- src/redis/src/PhpRedisClusterConnection.php | 16 +- src/redis/src/PhpRedisConnection.php | 28 +- src/redis/src/Pool/RedisPool.php | 5 +- src/redis/src/RedisConfig.php | 6 +- src/redis/src/RedisConnection.php | 44 +-- src/redis/src/RedisProxy.php | 20 +- src/redis/src/RedisSentinelFactory.php | 18 +- .../InteractsWithRedisParallelTest.php | 29 +- .../Integration/Redis/RedisConnectorTest.php | 27 +- .../PhpRedisClusterConnectionStub.php | 4 +- .../Redis/Fixtures/PhpRedisConnectionStub.php | 4 +- tests/Redis/PhpRedisClusterConnectionTest.php | 97 ++++-- tests/Redis/PoolFactoryTest.php | 4 + tests/Redis/RedisConfigTest.php | 40 ++- tests/Redis/RedisConnectionTest.php | 284 ++++++++++-------- tests/Redis/RedisManagerTest.php | 3 +- tests/Redis/RedisPoolHeartbeatTest.php | 3 +- tests/Redis/RedisPoolTest.php | 7 + tests/Redis/RedisProxyTest.php | 107 ++++++- tests/Redis/RedisSentinelFactoryTest.php | 99 +++--- 22 files changed, 622 insertions(+), 395 deletions(-) diff --git a/src/docs/redis.md b/src/docs/redis.md index 0aaea26f0..7700ac452 100644 --- a/src/docs/redis.md +++ b/src/docs/redis.md @@ -48,11 +48,20 @@ You may configure your application's Redis settings via the `config/database.php 'default' => [ 'url' => env('REDIS_URL'), + 'scheme' => null, 'host' => env('REDIS_HOST', 'localhost'), 'username' => env('REDIS_USERNAME'), 'password' => env('REDIS_PASSWORD'), 'port' => (int) env('REDIS_PORT', 6379), 'database' => (int) env('REDIS_DB', 0), + 'name' => null, + 'timeout' => null, + 'retry_interval' => 0, + 'read_timeout' => 0.0, + 'context' => [], + 'options' => [], + 'prefix' => null, + 'events' => false, 'max_retries' => (int) env('REDIS_MAX_RETRIES', 3), 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), 'backoff_base' => (int) env('REDIS_BACKOFF_BASE', 100), @@ -68,116 +77,62 @@ You may configure your application's Redis settings via the `config/database.php 'max_lifetime' => (float) env('REDIS_MAX_LIFETIME', -1), ], ], - - 'cache' => [ - 'url' => env('REDIS_CACHE_URL', env('REDIS_URL')), - 'host' => env('REDIS_CACHE_HOST', env('REDIS_HOST', 'localhost')), - 'username' => env('REDIS_CACHE_USERNAME', env('REDIS_USERNAME')), - 'password' => env('REDIS_CACHE_PASSWORD', env('REDIS_PASSWORD')), - 'port' => (int) env('REDIS_CACHE_PORT', env('REDIS_PORT', 6379)), - 'database' => (int) env('REDIS_CACHE_DB', env('REDIS_DB', 0)), - 'max_retries' => (int) env('REDIS_CACHE_MAX_RETRIES', env('REDIS_MAX_RETRIES', 3)), - 'backoff_algorithm' => env('REDIS_CACHE_BACKOFF_ALGORITHM', env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter')), - 'backoff_base' => (int) env('REDIS_CACHE_BACKOFF_BASE', env('REDIS_BACKOFF_BASE', 100)), - 'backoff_cap' => (int) env('REDIS_CACHE_BACKOFF_CAP', env('REDIS_BACKOFF_CAP', 1000)), - 'pool' => [ - 'min_connections' => (int) env('REDIS_CACHE_MIN_CONNECTIONS', env('REDIS_MIN_CONNECTIONS', 1)), - 'max_connections' => (int) env('REDIS_CACHE_MAX_CONNECTIONS', env('REDIS_MAX_CONNECTIONS', 10)), - 'connect_timeout' => 10.0, - 'wait_timeout' => 3.0, - 'heartbeat' => (float) env('REDIS_CACHE_HEARTBEAT', env('REDIS_HEARTBEAT', -1)), - 'heartbeat_timeout' => (float) env('REDIS_CACHE_HEARTBEAT_TIMEOUT', env('REDIS_HEARTBEAT_TIMEOUT', 1.0)), - 'max_idle_time' => (float) env('REDIS_CACHE_MAX_IDLE_TIME', env('REDIS_MAX_IDLE_TIME', 60)), - 'max_lifetime' => (float) env('REDIS_CACHE_MAX_LIFETIME', env('REDIS_MAX_LIFETIME', -1)), - ], - ], ], ``` -Hypervel's default configuration also includes `session`, `queue`, and `reverb` Redis connections. Each connection follows the same shape as the `default` and `cache` connections shown above. +Hypervel's default configuration also includes `cache`, `session`, `queue`, and `reverb` Redis connections. Each connection follows the same standalone shape as the `default` connection shown above. -Each standalone Redis server defined in your configuration file is required to have a name, host, and port unless you define a single URL to represent the Redis connection: +Each named connection is a complete standalone, Sentinel, or Cluster record. If you replace a connection in your application's configuration, include every option for the selected topology. Missing members are not filled from the framework's connection record. -```php -'redis' => [ - 'options' => [ - 'prefix' => env('REDIS_PREFIX', app_id() . ':'), - ], +For standalone and Sentinel connections, a null `name` disables `CLIENT SETNAME`. A null `timeout` uses the connection pool's `connect_timeout`, while a null `prefix` inherits the shared `redis.options.prefix` value. A null `scheme` leaves transport selection to the connection URL, stream context, or the default TCP transport. Cluster connections use their seeds when deriving a null scheme and do not have a client name. - 'default' => [ - 'url' => 'tcp://127.0.0.1:6379?database=0', - ], +You may define a single URL for a standalone connection instead of configuring its endpoint and credentials separately. Set the URL through the corresponding environment variable while keeping the rest of the complete connection record: - 'cache' => [ - 'url' => 'tls://user:password@127.0.0.1:6380?database=0', - ], -], +```ini +REDIS_URL="tcp://127.0.0.1:6379?database=0" +REDIS_CACHE_URL="tls://user:password@127.0.0.1:6380?database=0" ``` #### Configuring the Connection Scheme -By default, Redis connections will use the `tcp` scheme when connecting to your Redis servers. However, you may use TLS / SSL encryption by specifying a `scheme` configuration option in your Redis server's configuration array: +By default, Redis connections will use the `tcp` scheme when connecting to your Redis servers. However, you may use TLS / SSL encryption by changing the `scheme` member of the complete connection record: ```php -'default' => [ - 'scheme' => 'tls', - 'url' => env('REDIS_URL'), - 'host' => env('REDIS_HOST', 'localhost'), - 'username' => env('REDIS_USERNAME'), - 'password' => env('REDIS_PASSWORD'), - 'port' => (int) env('REDIS_PORT', 6379), - 'database' => (int) env('REDIS_DB', 0), -], +'scheme' => 'tls', ``` -When no scheme is specified, a non-empty `context` configuration also selects TLS. +When `scheme` is `null`, a non-empty `context` configuration selects TLS. ### PhpRedis -Hypervel communicates with Redis using the PhpRedis extension. In addition to the default configuration options, Hypervel supports the following connection parameters: `url`, `scheme`, `host`, `username`, `password`, `port`, `database`, `name`, `timeout`, `retry_interval`, `read_timeout`, `context`, `max_retries`, `backoff_algorithm`, `backoff_base`, and `backoff_cap`. +Hypervel communicates with Redis using the PhpRedis extension. Standalone connections support the following parameters: `url`, `scheme`, `host`, `username`, `password`, `port`, `database`, `name`, `timeout`, `retry_interval`, `read_timeout`, `context`, `options`, `prefix`, `events`, `max_retries`, `backoff_algorithm`, `backoff_base`, `backoff_cap`, and `pool`. ```php -'default' => [ - 'url' => env('REDIS_URL'), - 'host' => env('REDIS_HOST', 'localhost'), - 'username' => env('REDIS_USERNAME'), - 'password' => env('REDIS_PASSWORD'), - 'port' => (int) env('REDIS_PORT', 6379), - 'database' => (int) env('REDIS_DB', 0), - 'timeout' => 5.0, - 'retry_interval' => 0, - 'read_timeout' => 60, - 'name' => 'hypervel', - 'context' => [ - // 'stream' => ['verify_peer' => false], - ], +'timeout' => 5.0, +'retry_interval' => 0, +'read_timeout' => 60, +'name' => 'hypervel', +'context' => [ + // 'stream' => ['verify_peer' => false], ], ``` The `read_timeout` value is applied both when the Redis socket is opened and as the PhpRedis `Redis::OPT_READ_TIMEOUT` option. The optional `name` value sets the client name on standalone Redis connections. -The `context` option accepts stream options directly or nested under an `ssl` or `stream` key. If you need to configure PhpRedis options such as `prefix`, `scan`, `serializer`, `compression`, `compression_level`, `tcp_keepalive`, or `pack_ignore_numbers`, add them to the `options` array. The `pack_ignore_numbers` option requires PhpRedis 6.2 or later and applies only to standalone connections. Connection options override shared options, while a top-level connection `prefix` takes final precedence. +The `context` option accepts stream options directly or nested under an `ssl` or `stream` key. If you need to configure PhpRedis options such as `prefix`, `scan`, `serializer`, `compression`, `compression_level`, `tcp_keepalive`, or `pack_ignore_numbers`, add them to the `options` array. The `pack_ignore_numbers` option requires PhpRedis 6.2 or later and applies only to standalone connections. Connection options override shared options, while a non-null top-level connection `prefix` takes final precedence. #### Retry and Backoff Configuration -The `max_retries`, `backoff_algorithm`, `backoff_base`, and `backoff_cap` options may be used to configure how PhpRedis backs off between retry attempts. The following backoff algorithms are supported: `default`, `decorrelated_jitter`, `equal_jitter`, `exponential`, `uniform`, and `constant`: +The `max_retries`, `backoff_algorithm`, `backoff_base`, and `backoff_cap` members of each complete connection record configure how PhpRedis backs off between retry attempts. The following backoff algorithms are supported: `default`, `decorrelated_jitter`, `equal_jitter`, `exponential`, `uniform`, and `constant`: ```php -'default' => [ - 'url' => env('REDIS_URL'), - 'host' => env('REDIS_HOST', 'localhost'), - 'username' => env('REDIS_USERNAME'), - 'password' => env('REDIS_PASSWORD'), - 'port' => (int) env('REDIS_PORT', 6379), - 'database' => (int) env('REDIS_DB', 0), - 'max_retries' => (int) env('REDIS_MAX_RETRIES', 3), - 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), - 'backoff_base' => (int) env('REDIS_BACKOFF_BASE', 100), - 'backoff_cap' => (int) env('REDIS_BACKOFF_CAP', 1000), -], +'max_retries' => (int) env('REDIS_MAX_RETRIES', 3), +'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), +'backoff_base' => (int) env('REDIS_BACKOFF_BASE', 100), +'backoff_cap' => (int) env('REDIS_BACKOFF_CAP', 1000), ``` These settings control PhpRedis' native connection retry behavior. Hypervel does not replay a failed command because Redis may already have committed it before the failure became visible to the client. @@ -222,11 +177,29 @@ If your application is utilizing Redis Cluster, you should define a `cluster` ar ```php 'default' => [ + 'scheme' => null, 'username' => env('REDIS_USERNAME'), 'password' => env('REDIS_PASSWORD'), - 'timeout' => 5.0, - 'read_timeout' => 5.0, + 'timeout' => null, + 'read_timeout' => 0.0, 'context' => [], + 'options' => [], + 'prefix' => null, + 'events' => false, + 'max_retries' => (int) env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => (int) env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => (int) env('REDIS_BACKOFF_CAP', 1000), + 'pool' => [ + 'min_connections' => (int) env('REDIS_MIN_CONNECTIONS', 1), + 'max_connections' => (int) env('REDIS_MAX_CONNECTIONS', 10), + 'connect_timeout' => 10.0, + 'wait_timeout' => 3.0, + 'heartbeat' => (float) env('REDIS_HEARTBEAT', -1), + 'heartbeat_timeout' => (float) env('REDIS_HEARTBEAT_TIMEOUT', 1.0), + 'max_idle_time' => (float) env('REDIS_MAX_IDLE_TIME', 60), + 'max_lifetime' => (float) env('REDIS_MAX_LIFETIME', -1), + ], 'cluster' => [ 'enabled' => true, 'seeds' => explode(',', env('REDIS_CLUSTER_SEEDS', '127.0.0.1:6379')), @@ -234,7 +207,7 @@ If your application is utilizing Redis Cluster, you should define a `cluster` ar ], ``` -The `seeds` option should contain one or more `host:port` entries for nodes in the cluster. Redis Cluster does not support selecting logical databases, so the `database` option is ignored for clustered connections. +The `seeds` option should contain one or more `host:port` entries for nodes in the cluster. Redis Cluster does not support selecting logical databases, so Cluster records do not contain a `database` member. They also omit the standalone-only `url`, `host`, `port`, `name`, and `retry_interval` members. All nodes in a cluster connection must use the same transport. You may select TLS using the top-level `scheme` option, a `tls://` or `ssl://` seed, or a non-empty top-level `context` array. Bare seeds inherit the selected transport. Hypervel rejects conflicting schemes because PhpRedis applies one stream context to every node it discovers. @@ -261,12 +234,32 @@ Redis Sentinel provides high availability for Redis by monitoring your Redis mas ```php 'default' => [ + 'scheme' => null, 'username' => env('REDIS_USERNAME'), 'password' => env('REDIS_PASSWORD'), 'database' => (int) env('REDIS_DB', 0), - 'timeout' => 5.0, + 'name' => null, + 'timeout' => null, 'retry_interval' => 0, - 'read_timeout' => 5.0, + 'read_timeout' => 0.0, + 'context' => [], + 'options' => [], + 'prefix' => null, + 'events' => false, + 'max_retries' => (int) env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => (int) env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => (int) env('REDIS_BACKOFF_CAP', 1000), + 'pool' => [ + 'min_connections' => (int) env('REDIS_MIN_CONNECTIONS', 1), + 'max_connections' => (int) env('REDIS_MAX_CONNECTIONS', 10), + 'connect_timeout' => 10.0, + 'wait_timeout' => 3.0, + 'heartbeat' => (float) env('REDIS_HEARTBEAT', -1), + 'heartbeat_timeout' => (float) env('REDIS_HEARTBEAT_TIMEOUT', 1.0), + 'max_idle_time' => (float) env('REDIS_MAX_IDLE_TIME', 60), + 'max_lifetime' => (float) env('REDIS_MAX_LIFETIME', -1), + ], 'sentinel' => [ 'enabled' => true, 'master_name' => env('REDIS_SENTINEL_MASTER', 'mymaster'), @@ -275,6 +268,7 @@ Redis Sentinel provides high availability for Redis by monitoring your Redis mas 'password' => env('REDIS_SENTINEL_PASSWORD'), 'timeout' => 5.0, 'read_timeout' => 5.0, + 'context' => [], ], ], ``` diff --git a/src/foundation/src/Testing/RedisTestConfiguration.php b/src/foundation/src/Testing/RedisTestConfiguration.php index bf8ddaecf..311e28931 100644 --- a/src/foundation/src/Testing/RedisTestConfiguration.php +++ b/src/foundation/src/Testing/RedisTestConfiguration.php @@ -98,15 +98,23 @@ public static function configure(Repository $config, string|false $token): void ); } - $connection['database'] = $database; - if ($usesCluster) { - unset($connection['host'], $connection['port']); + unset( + $connection['url'], + $connection['host'], + $connection['port'], + $connection['database'], + $connection['name'], + $connection['retry_interval'], + $connection['sentinel'], + ); $connection['cluster'] = [ 'enabled' => true, 'seeds' => $clusterSeeds, ]; + } else { + $connection['database'] = $database; } $config->set("database.redis.{$name}", $connection); diff --git a/src/redis/src/PhpRedisClusterConnection.php b/src/redis/src/PhpRedisClusterConnection.php index 171e2cb04..21e6691c9 100644 --- a/src/redis/src/PhpRedisClusterConnection.php +++ b/src/redis/src/PhpRedisClusterConnection.php @@ -52,7 +52,7 @@ public function reconnect(): bool $this->connection = $redis; $this->markReconnected(); - if (($this->config['events'] ?? false) && $this->container->bound('events')) { + if ($this->config['events'] && $this->container->bound('events')) { $this->eventDispatcher = $this->container->make('events'); } @@ -281,17 +281,17 @@ protected function createRedisCluster(): RedisCluster try { $parameters = [ null, - $this->config['cluster']['seeds'] ?? [], - $this->config['timeout'] ?? 0.0, - $this->config['read_timeout'] ?? 0.0, + $this->config['cluster']['seeds'], + $this->config['timeout'], + $this->config['read_timeout'], false, $this->formatClusterPassword(), ]; - if (($this->config['scheme'] ?? 'tcp') === 'tls') { + if ($this->config['scheme'] === 'tls') { // RedisCluster needs the context argument to carry TLS to endpoints discovered after bootstrapping. $parameters[] = $this->normalizeClusterContext( - $this->config['context'] ?? [] + $this->config['context'] ); } @@ -327,8 +327,8 @@ protected function normalizeClusterContext(array $context): array */ protected function formatClusterPassword(): mixed { - $password = $this->config['password'] ?? null; - $username = $this->config['username'] ?? null; + $password = $this->config['password']; + $username = $this->config['username']; return $username !== null && $username !== '' && is_string($password) ? [$username, $password] diff --git a/src/redis/src/PhpRedisConnection.php b/src/redis/src/PhpRedisConnection.php index 8f413bbf6..7480fc36c 100644 --- a/src/redis/src/PhpRedisConnection.php +++ b/src/redis/src/PhpRedisConnection.php @@ -46,9 +46,9 @@ public function reconnect(): bool $this->setOptions($redis); - $auth = $this->config['password'] ?? null; + $auth = $this->config['password']; if ($auth !== null && $auth !== '') { - $username = $this->config['username'] ?? null; + $username = $this->config['username']; $redis->auth( $username !== null && $username !== '' && is_string($auth) ? [$username, $auth] @@ -56,12 +56,12 @@ public function reconnect(): bool ); } - $database = $this->database ?? (int) ($this->config['database'] ?? 0); + $database = $this->database ?? (int) $this->config['database']; if ($database > 0) { $redis->select($database); } - $name = $this->config['name'] ?? null; + $name = $this->config['name']; if ($name !== null && $name !== '') { $redis->client('SETNAME', $name); } @@ -69,7 +69,7 @@ public function reconnect(): bool $this->connection = $redis; $this->markReconnected(); - if (($this->config['events'] ?? false) && $this->container->bound('events')) { + if ($this->config['events'] && $this->container->bound('events')) { $this->eventDispatcher = $this->container->make('events'); } @@ -116,13 +116,13 @@ protected function createRedis(array $config): Redis $parameters = [ $this->formatHost($config), (int) $config['port'], - $config['timeout'] ?? 0.0, + $config['timeout'], null, - $config['retry_interval'] ?? 0, - $config['read_timeout'] ?? 0.0, + $config['retry_interval'], + $config['read_timeout'], ]; - if (! empty($config['context'])) { + if ($config['context'] !== []) { $parameters[] = $this->normalizeContext($config['context']); } @@ -198,13 +198,13 @@ protected function createRedisSentinel(): Redis ->resolveMaster($this->config); $redis = $this->createRedis([ - 'scheme' => $this->config['scheme'] ?? null, + 'scheme' => $this->config['scheme'], 'host' => $host, 'port' => $port, - 'timeout' => $this->config['timeout'] ?? 0, - 'retry_interval' => $this->config['retry_interval'] ?? 0, - 'read_timeout' => $this->config['read_timeout'] ?? 0, - 'context' => $this->config['context'] ?? [], + 'timeout' => $this->config['timeout'], + 'retry_interval' => $this->config['retry_interval'], + 'read_timeout' => $this->config['read_timeout'], + 'context' => $this->config['context'], ]); } catch (Throwable $exception) { throw new ConnectionException('Connection reconnect failed ' . $exception->getMessage()); diff --git a/src/redis/src/Pool/RedisPool.php b/src/redis/src/Pool/RedisPool.php index 6804f4388..458bed3e7 100644 --- a/src/redis/src/Pool/RedisPool.php +++ b/src/redis/src/Pool/RedisPool.php @@ -13,7 +13,6 @@ use Hypervel\Redis\PhpRedisConnection; use Hypervel\Redis\RedisConfig; use Hypervel\Redis\RedisConnection; -use Hypervel\Support\Arr; use Throwable; class RedisPool extends Pool @@ -31,13 +30,13 @@ public function __construct(Container $container, string $name) { $configService = $container->make(RedisConfig::class); $this->config = $configService->connectionConfig($name); - $poolOptions = Arr::get($this->config, 'pool', []); + $poolOptions = $this->config['pool']; $this->frequency = new Frequency; parent::__construct($container, $name, $poolOptions); - if (! array_key_exists('timeout', $this->config)) { + if ($this->config['timeout'] === null) { $this->config['timeout'] = $this->option->getConnectTimeout(); } diff --git a/src/redis/src/RedisConfig.php b/src/redis/src/RedisConfig.php index fdea9e0cf..842d4cd26 100644 --- a/src/redis/src/RedisConfig.php +++ b/src/redis/src/RedisConfig.php @@ -43,19 +43,19 @@ public function connectionConfig(string $name): array $connectionConfig = $this->normalizeClusterConfiguration($name, $connectionConfig); } - $sharedOptions = $redisConfig['options'] ?? []; + $sharedOptions = $redisConfig['options']; if (! is_array($sharedOptions)) { throw new InvalidArgumentException('The redis options config must be an array.'); } - $connectionOptions = $connectionConfig['options'] ?? []; + $connectionOptions = $connectionConfig['options']; if (! is_array($connectionOptions)) { throw new InvalidArgumentException(sprintf('The redis connection [%s] options must be an array.', $name)); } $connectionConfig['options'] = array_replace($sharedOptions, $connectionOptions); - if (array_key_exists('prefix', $connectionConfig)) { + if (isset($connectionConfig['prefix'])) { $connectionConfig['options']['prefix'] = $connectionConfig['prefix']; } diff --git a/src/redis/src/RedisConnection.php b/src/redis/src/RedisConnection.php index 18084323f..7a62f8451 100644 --- a/src/redis/src/RedisConnection.php +++ b/src/redis/src/RedisConnection.php @@ -352,28 +352,7 @@ abstract class RedisConnection extends BaseConnection protected ?Dispatcher $eventDispatcher = null; - protected array $config = [ - 'timeout' => 0.0, - 'retry_interval' => 0, - 'read_timeout' => 0.0, - 'cluster' => [ - 'enabled' => false, - 'seeds' => [], - ], - 'sentinel' => [ - 'enabled' => false, - 'master_name' => '', - 'nodes' => [], - 'username' => null, - 'password' => null, - 'timeout' => 0.0, - 'read_timeout' => 0.0, - 'context' => [], - ], - 'options' => [], - 'context' => [], - 'events' => false, - ]; + protected array $config; /** * Current redis database. @@ -398,7 +377,7 @@ abstract class RedisConnection extends BaseConnection public function __construct(Container $container, PoolInterface $pool, array $config) { parent::__construct($container, $pool); - $this->config = array_replace_recursive($this->config, $config); + $this->config = $config; } /** @@ -615,16 +594,16 @@ protected function configuredPhpRedisOptions(): array $connectionOptions = []; foreach (self::CONNECTION_LEVEL_PHPREDIS_OPTIONS as $key) { - if ($key === 'read_timeout' && empty($this->config[$key])) { + $value = $this->config[$key]; + + if ($key === 'read_timeout' && empty($value)) { continue; } - if (array_key_exists($key, $this->config)) { - $connectionOptions[$key] = $this->config[$key]; - } + $connectionOptions[$key] = $value; } - return array_replace($connectionOptions, $this->config['options'] ?? []); + return array_replace($connectionOptions, $this->config['options']); } /** @@ -777,10 +756,13 @@ public function release(): void } try { - $defaultDatabase = (int) ($this->config['database'] ?? 0); + // Cluster connections never select logical databases and omit this config member. + if ($this->database !== null) { + $defaultDatabase = (int) $this->config['database']; - if ($this->database !== null && $this->database !== $defaultDatabase) { - $this->select($defaultDatabase); + if ($this->database !== $defaultDatabase) { + $this->select($defaultDatabase); + } } } catch (Throwable $exception) { $this->markInvalid(); diff --git a/src/redis/src/RedisProxy.php b/src/redis/src/RedisProxy.php index 101fb2fec..de20444ac 100644 --- a/src/redis/src/RedisProxy.php +++ b/src/redis/src/RedisProxy.php @@ -521,8 +521,8 @@ public function subscriber(): Subscriber $config, $host, $port, - $config['scheme'] ?? null, - $config['context'] ?? [], + $config['scheme'], + $config['context'], ); } @@ -531,8 +531,8 @@ public function subscriber(): Subscriber $config, $config['host'], (int) $config['port'], - $config['scheme'] ?? null, - $config['context'] ?? [], + $config['scheme'], + $config['context'], ); } @@ -582,8 +582,8 @@ public function subscriber(): Subscriber $config, $master[0], (int) $master[1], - $config['scheme'] ?? null, - $config['context'] ?? [], + $config['scheme'], + $config['context'], ); } catch (Throwable $exception) { $failures[] = sprintf( @@ -615,17 +615,17 @@ private function createSubscriber( array $context, ): Subscriber { /** @var null|array|string $password */ - $password = $config['password'] ?? null; + $password = $config['password']; /** @var null|string $username */ - $username = $config['username'] ?? null; + $username = $config['username']; return new Subscriber( host: $host, port: $port, password: $password, - timeout: (float) ($config['timeout'] ?? 5.0), - prefix: (string) (($config['options'] ?? [])['prefix'] ?? ''), + timeout: (float) $config['timeout'], + prefix: (string) ($config['options']['prefix'] ?? ''), username: $username, scheme: $scheme, context: $context, diff --git a/src/redis/src/RedisSentinelFactory.php b/src/redis/src/RedisSentinelFactory.php index 8bb8f64da..f3262ce0c 100644 --- a/src/redis/src/RedisSentinelFactory.php +++ b/src/redis/src/RedisSentinelFactory.php @@ -29,8 +29,8 @@ public function create(array $options = []): RedisSentinel */ public function resolveMaster(array $config): array { - $sentinel = $config['sentinel'] ?? []; - $nodes = $sentinel['nodes'] ?? []; + $sentinel = $config['sentinel']; + $nodes = $sentinel['nodes']; $failures = []; shuffle($nodes); @@ -62,25 +62,25 @@ public function resolveMaster(array $config): array ? "{$resolved['scheme']}://{$resolved['host']}" : $resolved['host'], 'port' => (int) $resolved['port'], - 'connectTimeout' => (float) ($sentinel['timeout'] ?? 0), - 'readTimeout' => (float) ($sentinel['read_timeout'] ?? 0), + 'connectTimeout' => (float) $sentinel['timeout'], + 'readTimeout' => (float) $sentinel['read_timeout'], ]; - $context = $sentinel['context'] ?? []; - $password = $sentinel['password'] ?? null; + $context = $sentinel['context']; + $password = $sentinel['password']; if ($context !== []) { $options['ssl'] = $this->normalizeContext($context); } if ($password !== null && $password !== '') { - $username = $sentinel['username'] ?? null; + $username = $sentinel['username']; $options['auth'] = $username !== null && $username !== '' && is_string($password) ? [$username, $password] : $password; } $master = $this->create($options)->getMasterAddrByName( - (string) ($sentinel['master_name'] ?? '') + (string) $sentinel['master_name'] ); if (is_array($master) @@ -99,7 +99,7 @@ public function resolveMaster(array $config): array throw new InvalidRedisConnectionException(sprintf( 'Unable to resolve Redis master [%s] from Sentinel nodes: %s.', - $sentinel['master_name'] ?? '', + $sentinel['master_name'], implode('; ', $failures), )); } diff --git a/tests/Foundation/Testing/Concerns/InteractsWithRedisParallelTest.php b/tests/Foundation/Testing/Concerns/InteractsWithRedisParallelTest.php index 6aca6e9e9..c27774f36 100644 --- a/tests/Foundation/Testing/Concerns/InteractsWithRedisParallelTest.php +++ b/tests/Foundation/Testing/Concerns/InteractsWithRedisParallelTest.php @@ -9,6 +9,7 @@ use Hypervel\Foundation\Testing\Concerns\InteractsWithRedis; use Hypervel\Foundation\Testing\RedisTestConfiguration; use Hypervel\Foundation\Testing\RedisTestDatabases; +use Hypervel\Redis\RedisConfig; use Hypervel\Support\Env; use Hypervel\Testbench\TestCase; use Hypervel\Testing\ParallelTesting; @@ -349,7 +350,7 @@ public function testParallelSetupNormalizesEveryConfiguredConnectionToTheWorkerD $this->assertSame(6, $config->integer('database.redis.reverb.database')); } - public function testClusterSetupConfiguresEveryNamedConnectionFromClusterEnvironment(): void + public function testClusterSetupBuildsCompleteClusterRecordsFromEveryNamedConnection(): void { $this->setRedisEnvironmentValue('REDIS_HOST', 'standalone-redis'); $this->setRedisEnvironmentValue( @@ -369,9 +370,10 @@ public function testClusterSetupConfiguresEveryNamedConnectionFromClusterEnviron foreach (['default', 'cache', 'session', 'queue', 'reverb'] as $connectionName) { $connection = $config->array("database.redis.{$connectionName}"); - $this->assertSame(0, $connection['database']); - $this->assertArrayNotHasKey('host', $connection); - $this->assertArrayNotHasKey('port', $connection); + foreach (['url', 'host', 'port', 'database', 'name', 'retry_interval', 'sentinel'] as $member) { + $this->assertArrayNotHasKey($member, $connection); + } + $this->assertSame([ 'enabled' => true, 'seeds' => [ @@ -380,13 +382,12 @@ public function testClusterSetupConfiguresEveryNamedConnectionFromClusterEnviron 'redis-cluster-3:6379', ], ], $connection['cluster']); + $this->assertSame( + 'tcp', + $this->app->make(RedisConfig::class)->connectionConfig($connectionName)['scheme'], + ); } - $this->assertSame([ - 'enabled' => true, - 'nodes' => ['redis-sentinel:26379'], - 'master_name' => 'primary', - ], $config->array('database.redis.default.sentinel')); $this->assertTrue($harness->usesCluster()); $this->assertSame(1, $harness->flushRedisCalls); } @@ -413,8 +414,14 @@ public function testDynamicConnectionsInheritTheConfiguredClusterTopology(): voi $this->assertSame(['enabled' => true, 'seeds' => ['redis-cluster-1:6379']], $connection['cluster']); $this->assertSame(['prefix' => ''], $connection['options']); $this->assertSame(3, $connection['pool']['max_connections']); - $this->assertArrayNotHasKey('host', $connection); - $this->assertArrayNotHasKey('port', $connection); + + foreach (['url', 'host', 'port', 'database', 'name', 'retry_interval', 'sentinel'] as $member) { + $this->assertArrayNotHasKey($member, $connection); + } + + $effectiveConnection = $this->app->make(RedisConfig::class)->connectionConfig($connectionName); + $this->assertSame('tls', $effectiveConnection['scheme']); + $this->assertSame(['prefix' => ''], $effectiveConnection['options']); } public function testSetupIgnoresMetadataAndNonConnectionConfiguration(): void diff --git a/tests/Integration/Redis/RedisConnectorTest.php b/tests/Integration/Redis/RedisConnectorTest.php index db626bb8a..aab08ef21 100644 --- a/tests/Integration/Redis/RedisConnectorTest.php +++ b/tests/Integration/Redis/RedisConnectorTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Integration\Redis; use Closure; +use ErrorException; use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Foundation\Testing\Concerns\InteractsWithRedis; use Hypervel\Redis\RedisConnection; @@ -169,6 +170,19 @@ public function testTcpKeepaliveOptionIsApplied(): void }); } + public function testIncompleteConnectionFailsBeforeOpeningASocket(): void + { + $name = $this->addTestConnection([]); + $connection = $this->app->make('config')->array("database.redis.{$name}"); + unset($connection['options']); + $this->app->make('config')->set("database.redis.{$name}", $connection); + + $this->expectException(ErrorException::class); + $this->expectExceptionMessage('Undefined array key "options"'); + + Redis::connection($name)->get('missing-schema'); + } + /** * Execute a callback with the underlying phpredis client for a named connection. * @@ -194,17 +208,10 @@ private function addTestConnection(array $config): string { static $counter = 0; $name = 'connector_test_' . ++$counter; + $connection = $this->app->make('config')->array('database.redis.default'); + $connection['pool']['max_connections'] = 2; - $config = array_merge([ - 'pool' => [ - 'min_connections' => 1, - 'max_connections' => 2, - 'connect_timeout' => 10.0, - 'wait_timeout' => 3.0, - 'heartbeat' => -1, - 'max_idle_time' => 60.0, - ], - ], $config); + $config = array_replace($connection, $config); $this->app->make('config')->set("database.redis.{$name}", $config); diff --git a/tests/Redis/Fixtures/PhpRedisClusterConnectionStub.php b/tests/Redis/Fixtures/PhpRedisClusterConnectionStub.php index 9f6dcf8b7..fb85c281a 100644 --- a/tests/Redis/Fixtures/PhpRedisClusterConnectionStub.php +++ b/tests/Redis/Fixtures/PhpRedisClusterConnectionStub.php @@ -24,7 +24,7 @@ class PhpRedisClusterConnectionStub extends PhpRedisClusterConnection public function __construct(?Container $container = null, ?PoolInterface $pool = null, array $config = []) { if ($container !== null && $pool !== null) { - // Call grandparent to merge config without reconnecting + // Call the grandparent to store config without reconnecting. \Hypervel\Redis\RedisConnection::__construct($container, $pool, $config); } // Skip reconnect() — tests inject connections via setActiveConnection() @@ -75,7 +75,7 @@ public function getConnection(): Redis|RedisCluster } /** - * Get the merged connection configuration. + * Get the connection configuration. * * @return array */ diff --git a/tests/Redis/Fixtures/PhpRedisConnectionStub.php b/tests/Redis/Fixtures/PhpRedisConnectionStub.php index fac3dd5f9..0cc1ae505 100644 --- a/tests/Redis/Fixtures/PhpRedisConnectionStub.php +++ b/tests/Redis/Fixtures/PhpRedisConnectionStub.php @@ -24,7 +24,7 @@ class PhpRedisConnectionStub extends PhpRedisConnection public function __construct(?Container $container = null, ?PoolInterface $pool = null, array $config = []) { if ($container !== null && $pool !== null) { - // Call grandparent to merge config without reconnecting + // Call the grandparent to store config without reconnecting. \Hypervel\Redis\RedisConnection::__construct($container, $pool, $config); } // Skip reconnect() — tests inject connections via setActiveConnection() @@ -75,7 +75,7 @@ public function getConnection(): Redis|RedisCluster } /** - * Get the merged connection configuration. + * Get the connection configuration. * * @return array */ diff --git a/tests/Redis/PhpRedisClusterConnectionTest.php b/tests/Redis/PhpRedisClusterConnectionTest.php index 30bb9a9b1..444a23d63 100644 --- a/tests/Redis/PhpRedisClusterConnectionTest.php +++ b/tests/Redis/PhpRedisClusterConnectionTest.php @@ -391,9 +391,9 @@ public static function standaloneErrorSemantics(): array ]; } - public function testFormatClusterPasswordReturnsArrayWhenUsernameAndPasswordProvided() + public function testFormatClusterPasswordReturnsArrayWhenUsernameAndPasswordProvided(): void { - $connection = new class($this->getContainer(), $this->getMockedPool(), ['username' => 'myuser', 'password' => 'mypass']) extends PhpRedisClusterConnectionStub { + $connection = new class($this->getContainer(), $this->getMockedPool(), $this->clusterConfig(['username' => 'myuser', 'password' => 'mypass'])) extends PhpRedisClusterConnectionStub { public function formatClusterPasswordForTest(): mixed { return $this->formatClusterPassword(); @@ -405,7 +405,7 @@ public function formatClusterPasswordForTest(): mixed public function testFormatClusterPasswordPreservesZeroCredentials(): void { - $connection = new class($this->getContainer(), $this->getMockedPool(), ['username' => '0', 'password' => '0']) extends PhpRedisClusterConnectionStub { + $connection = new class($this->getContainer(), $this->getMockedPool(), $this->clusterConfig(['username' => '0', 'password' => '0'])) extends PhpRedisClusterConnectionStub { public function formatClusterPasswordForTest(): mixed { return $this->formatClusterPassword(); @@ -458,15 +458,14 @@ public function testClusterSchemeSelectsTheExpectedTransport(string $scheme, arr new PhpRedisClusterConnection( $this->getContainer(), $this->getMockedPool(), - [ + $this->clusterConfig([ 'scheme' => $scheme, 'context' => $context, - 'timeout' => 1.0, 'cluster' => [ 'enabled' => true, 'seeds' => ["{$scheme}://{$host}:{$port}"], ], - ], + ]), ); } catch (ConnectionException $exception) { $failure = $exception; @@ -493,7 +492,7 @@ public static function clusterTransports(): array public function testClusterOptionsUseNativeFailoverAndTcpKeepaliveConstants(): void { - $connection = new class($this->getContainer(), $this->getMockedPool(), ['options' => ['failover' => RedisCluster::FAILOVER_DISTRIBUTE, 'tcp_keepalive' => 30, 'pack_ignore_numbers' => true]]) extends PhpRedisClusterConnectionStub { + $connection = new class($this->getContainer(), $this->getMockedPool(), $this->clusterConfig(['options' => ['failover' => RedisCluster::FAILOVER_DISTRIBUTE, 'tcp_keepalive' => 30, 'pack_ignore_numbers' => true]])) extends PhpRedisClusterConnectionStub { public function setOptionsForTest(RedisCluster $redis): void { $this->setOptions($redis); @@ -506,13 +505,14 @@ public function setOptionsForTest(RedisCluster $redis): void $redis->expects('setOption') ->with(Redis::OPT_TCP_KEEPALIVE, 30) ->andReturnTrue(); + $this->expectDefaultConnectionOptions($redis); $connection->setOptionsForTest($redis); } - public function testFormatClusterPasswordReturnsPlainPasswordWithoutUsername() + public function testFormatClusterPasswordReturnsPlainPasswordWithoutUsername(): void { - $connection = new class($this->getContainer(), $this->getMockedPool(), ['password' => 'mypass']) extends PhpRedisClusterConnectionStub { + $connection = new class($this->getContainer(), $this->getMockedPool(), $this->clusterConfig(['password' => 'mypass'])) extends PhpRedisClusterConnectionStub { public function formatClusterPasswordForTest(): mixed { return $this->formatClusterPassword(); @@ -522,9 +522,9 @@ public function formatClusterPasswordForTest(): mixed $this->assertSame('mypass', $connection->formatClusterPasswordForTest()); } - public function testFormatClusterPasswordReturnsNullWhenNoPasswordProvided() + public function testFormatClusterPasswordReturnsNullWhenNoPasswordProvided(): void { - $connection = new class($this->getContainer(), $this->getMockedPool(), []) extends PhpRedisClusterConnectionStub { + $connection = new class($this->getContainer(), $this->getMockedPool(), $this->clusterConfig()) extends PhpRedisClusterConnectionStub { public function formatClusterPasswordForTest(): mixed { return $this->formatClusterPassword(); @@ -534,9 +534,9 @@ public function formatClusterPasswordForTest(): mixed $this->assertNull($connection->formatClusterPasswordForTest()); } - public function testFormatClusterPasswordReturnsPlainPasswordWhenUsernameIsEmpty() + public function testFormatClusterPasswordReturnsPlainPasswordWhenUsernameIsEmpty(): void { - $connection = new class($this->getContainer(), $this->getMockedPool(), ['username' => '', 'password' => 'mypass']) extends PhpRedisClusterConnectionStub { + $connection = new class($this->getContainer(), $this->getMockedPool(), $this->clusterConfig(['username' => '', 'password' => 'mypass'])) extends PhpRedisClusterConnectionStub { public function formatClusterPasswordForTest(): mixed { return $this->formatClusterPassword(); @@ -546,9 +546,9 @@ public function formatClusterPasswordForTest(): mixed $this->assertSame('mypass', $connection->formatClusterPasswordForTest()); } - public function testFormatClusterPasswordReturnsPlainPasswordWhenPasswordIsNotString() + public function testFormatClusterPasswordReturnsPlainPasswordWhenPasswordIsNotString(): void { - $connection = new class($this->getContainer(), $this->getMockedPool(), ['username' => 'myuser', 'password' => ['mypass']]) extends PhpRedisClusterConnectionStub { + $connection = new class($this->getContainer(), $this->getMockedPool(), $this->clusterConfig(['username' => 'myuser', 'password' => ['mypass']])) extends PhpRedisClusterConnectionStub { public function formatClusterPasswordForTest(): mixed { return $this->formatClusterPassword(); @@ -558,7 +558,7 @@ public function formatClusterPasswordForTest(): mixed $this->assertSame(['mypass'], $connection->formatClusterPasswordForTest()); } - public function testDefaultNodeIsCached() + public function testDefaultNodeIsCached(): void { $client = m::mock(RedisCluster::class); $client->shouldReceive('_masters') @@ -578,7 +578,7 @@ public function testDefaultNodeIsCached() $connection->scan($cursor, ['match' => '*']); } - public function testDefaultNodeThrowsWhenNoMasters() + public function testDefaultNodeThrowsWhenNoMasters(): void { $client = m::mock(RedisCluster::class); $client->shouldReceive('_masters') @@ -596,7 +596,7 @@ public function testDefaultNodeThrowsWhenNoMasters() $connection->scan($cursor, ['match' => '*']); } - public function testReconnectClearsCachedDefaultNode() + public function testReconnectClearsCachedDefaultNode(): void { $pool = m::mock(PoolInterface::class); $pool->shouldReceive('getOption')->andReturn(new PoolOption); @@ -609,14 +609,16 @@ public function testReconnectClearsCachedDefaultNode() $clientA = m::mock(RedisCluster::class); $clientA->shouldReceive('_masters')->once()->andReturn([['10.0.0.1', 6379]]); $clientA->shouldReceive('scan')->andReturn(false); + $clientA->shouldReceive('setOption')->andReturnTrue(); // Second client (after reconnect): master is node B $clientB = m::mock(RedisCluster::class); $clientB->shouldReceive('_masters')->once()->andReturn([['10.0.0.2', 6379]]); $clientB->shouldReceive('scan')->andReturn(false); + $clientB->shouldReceive('setOption')->andReturnTrue(); $callCount = 0; - $connection = new class($container, $pool, ['cluster' => ['enabled' => true, 'seeds' => ['tcp://10.0.0.1:6379']]], $clientA, $clientB, $callCount) extends PhpRedisClusterConnection { + $connection = new class($container, $pool, $this->clusterConfig(['cluster' => ['enabled' => true, 'seeds' => ['tcp://10.0.0.1:6379']]]), $clientA, $clientB, $callCount) extends PhpRedisClusterConnection { public function __construct( ContainerContract $container, PoolInterface $pool, @@ -625,7 +627,7 @@ public function __construct( private RedisCluster $clientB, private int &$callCount, ) { - // Call grandparent to merge config, then reconnect via our override + // Store config without invoking the parent constructor's reconnect. \Hypervel\Redis\RedisConnection::__construct($container, $pool, $config); $this->reconnect(); } @@ -653,6 +655,58 @@ protected function createRedisCluster(): RedisCluster // proving the cache was cleared and re-populated after reconnect. } + /** + * Create a complete Cluster Redis connection record. + */ + private function clusterConfig(array $overrides = []): array + { + return array_replace([ + 'scheme' => 'tcp', + 'username' => null, + 'password' => null, + 'timeout' => 1.0, + 'read_timeout' => 0.0, + 'context' => [], + 'options' => [], + 'prefix' => null, + 'events' => false, + 'max_retries' => 3, + 'backoff_algorithm' => 'decorrelated_jitter', + 'backoff_base' => 100, + 'backoff_cap' => 1000, + 'pool' => [ + 'min_connections' => 1, + 'max_connections' => 10, + 'connect_timeout' => 10.0, + 'wait_timeout' => 3.0, + 'heartbeat' => -1.0, + 'heartbeat_timeout' => 1.0, + 'max_idle_time' => 60.0, + 'max_lifetime' => -1.0, + ], + 'cluster' => [ + 'enabled' => true, + 'seeds' => ['tcp://127.0.0.1:7000'], + ], + ], $overrides); + } + + /** + * Expect the default connection-level phpredis options. + */ + private function expectDefaultConnectionOptions(RedisCluster $redis): void + { + $redis->expects('setOption')->with(Redis::OPT_MAX_RETRIES, 3)->andReturnTrue(); + $redis->expects('setOption') + ->with(Redis::OPT_BACKOFF_ALGORITHM, Redis::BACKOFF_ALGORITHM_DECORRELATED_JITTER) + ->andReturnTrue(); + $redis->expects('setOption')->with(Redis::OPT_BACKOFF_BASE, 100)->andReturnTrue(); + $redis->expects('setOption')->with(Redis::OPT_BACKOFF_CAP, 1000)->andReturnTrue(); + } + + /** + * Get a mocked Redis pool. + */ private function getMockedPool(): PoolInterface { $pool = m::mock(PoolInterface::class); @@ -661,6 +715,9 @@ private function getMockedPool(): PoolInterface return $pool; } + /** + * Get a mocked container. + */ private function getContainer(): ContainerContract { $container = m::mock(ContainerContract::class); diff --git a/tests/Redis/PoolFactoryTest.php b/tests/Redis/PoolFactoryTest.php index a86232acf..7c9e00c67 100644 --- a/tests/Redis/PoolFactoryTest.php +++ b/tests/Redis/PoolFactoryTest.php @@ -169,12 +169,16 @@ function () use ($factory, &$resolvedDuringClose): void { $this->assertSame($replacement, $factory->getPool('default')); } + /** + * Mock a container with Redis pools. + */ private function mockContainerWithPools(): m\MockInterface|ContainerContract { $connectionConfig = [ 'host' => 'localhost', 'port' => 6379, 'database' => 0, + 'timeout' => null, 'pool' => [ 'min_connections' => 1, 'max_connections' => 10, diff --git a/tests/Redis/RedisConfigTest.php b/tests/Redis/RedisConfigTest.php index b87b84c28..cf1fb5bdf 100644 --- a/tests/Redis/RedisConfigTest.php +++ b/tests/Redis/RedisConfigTest.php @@ -18,7 +18,8 @@ public function testConnectionConfigAcceptsPhpRedisClient(): void $config = m::mock(Repository::class); $config->shouldReceive('array')->with('database.redis')->andReturn([ 'client' => 'phpredis', - 'default' => ['host' => '127.0.0.1', 'port' => 6379, 'database' => 0], + 'options' => [], + 'default' => ['host' => '127.0.0.1', 'port' => 6379, 'database' => 0, 'options' => []], ]); $connection = (new RedisConfig($config))->connectionConfig('default'); @@ -168,10 +169,12 @@ public function testEventOverridePreservesConfigUntilExplicitlyChanged(): void { $config = m::mock(Repository::class); $config->shouldReceive('array')->with('database.redis')->andReturn([ + 'options' => [], 'default' => [ 'host' => '127.0.0.1', 'port' => 6379, 'events' => true, + 'options' => [], ], ]); $redisConfig = new RedisConfig($config); @@ -189,9 +192,11 @@ public function testEventOverrideCreatesEventConfigForFutureAssemblies(): void { $config = m::mock(Repository::class); $config->shouldReceive('array')->with('database.redis')->andReturn([ + 'options' => [], 'default' => [ 'host' => '127.0.0.1', 'port' => 6379, + 'options' => [], ], ]); $redisConfig = new RedisConfig($config); @@ -231,12 +236,32 @@ public function testConnectionConfigThrowsForInvalidConnectionOptions(): void (new RedisConfig($config))->connectionConfig('default'); } + public function testNullConnectionPrefixInheritsSharedPrefix(): void + { + $config = m::mock(Repository::class); + $config->shouldReceive('array')->with('database.redis')->andReturn([ + 'options' => ['prefix' => 'shared:'], + 'default' => [ + 'host' => '127.0.0.1', + 'port' => 6379, + 'options' => ['scan' => 1], + 'prefix' => null, + ], + ]); + + $connection = (new RedisConfig($config))->connectionConfig('default'); + + $this->assertSame(['prefix' => 'shared:', 'scan' => 1], $connection['options']); + } + public function testConnectionConfigAcceptsClusterConnectionWithoutHostAndPort(): void { $config = m::mock(Repository::class); $config->shouldReceive('array')->with('database.redis')->andReturn([ + 'options' => [], 'clustered' => [ 'database' => 0, + 'options' => [], 'cluster' => [ 'enabled' => true, 'seeds' => ['127.0.0.1:7000', '127.0.0.1:7001'], @@ -295,8 +320,10 @@ public function testConfiguredTlsSchemeSecuresBareClusterSeeds(): void { $config = m::mock(Repository::class); $config->shouldReceive('array')->with('database.redis')->andReturn([ + 'options' => [], 'clustered' => [ 'scheme' => 'tls', + 'options' => [], 'cluster' => [ 'enabled' => true, 'seeds' => ['127.0.0.1:7000'], @@ -315,7 +342,9 @@ public function testSecureClusterSeedSelectsTlsTransport(): void { $config = m::mock(Repository::class); $config->shouldReceive('array')->with('database.redis')->andReturn([ + 'options' => [], 'clustered' => [ + 'options' => [], 'cluster' => [ 'enabled' => true, 'seeds' => ['ssl://127.0.0.1:7000', '127.0.0.1:7001'], @@ -337,8 +366,10 @@ public function testNonEmptyClusterContextSelectsTlsTransport(): void $context = ['ssl' => ['verify_peer' => true]]; $config = m::mock(Repository::class); $config->shouldReceive('array')->with('database.redis')->andReturn([ + 'options' => [], 'clustered' => [ 'context' => $context, + 'options' => [], 'cluster' => [ 'enabled' => true, 'seeds' => ['127.0.0.1:7000'], @@ -425,8 +456,10 @@ public function testConnectionConfigAcceptsSentinelConnectionWithoutHostAndPort( { $config = m::mock(Repository::class); $config->shouldReceive('array')->with('database.redis')->andReturn([ + 'options' => [], 'sentinel' => [ 'database' => 0, + 'options' => [], 'sentinel' => [ 'enabled' => true, 'nodes' => ['tcp://127.0.0.1:26379'], @@ -513,6 +546,7 @@ public function testConnectionConfigParsesUrl(): void 'options' => [], 'default' => [ 'url' => 'redis://myuser:secret@redis.example.com:6380/3', + 'options' => [], ], ]); @@ -535,6 +569,7 @@ public function testConnectionConfigUrlOverridesExplicitValues(): void 'host' => 'confighost', 'port' => 6379, 'database' => 0, + 'options' => [], ], ]); @@ -554,6 +589,7 @@ public function testConnectionConfigWithoutUrlPreservesExplicitValues(): void 'host' => '127.0.0.1', 'port' => 6379, 'database' => 0, + 'options' => [], ], ]); @@ -568,8 +604,10 @@ public function testConnectionConfigAcceptsUrlOnlyConnection(): void { $config = m::mock(Repository::class); $config->shouldReceive('array')->with('database.redis')->andReturn([ + 'options' => [], 'default' => [ 'url' => 'redis://127.0.0.1:6379/0', + 'options' => [], ], ]); diff --git a/tests/Redis/RedisConnectionTest.php b/tests/Redis/RedisConnectionTest.php index 54361d802..e010d6a79 100644 --- a/tests/Redis/RedisConnectionTest.php +++ b/tests/Redis/RedisConnectionTest.php @@ -70,36 +70,9 @@ public function testReleaseResetsDatabaseToConfiguredDefault(): void $redis->shouldReceive('select')->once()->with(1)->andReturn(true); $redis->shouldReceive('getMode')->once()->andReturn(Redis::ATOMIC); - $connection = new class($this->getContainer(), $pool, ['host' => '127.0.0.1', 'port' => 6379, 'database' => 1], $redis) extends PhpRedisConnection { - public function __construct( - ContainerContract $container, - PoolInterface $pool, - array $config, - private Redis $fakeRedis - ) { - parent::__construct($container, $pool, $config); - } - - protected function createRedis(array $config): Redis - { - return $this->fakeRedis; - } - }; - - $connection->setDatabase(2); - $connection->release(); - } + $redis->shouldReceive('setOption')->andReturnTrue(); - public function testReleaseDefaultsToDatabaseZeroWhenDbConfigIsMissing(): void - { - $pool = $this->getMockedPool(); - $pool->shouldReceive('release')->once(); - - $redis = m::mock(Redis::class); - $redis->shouldReceive('select')->once()->with(0)->andReturn(true); - $redis->shouldReceive('getMode')->once()->andReturn(Redis::ATOMIC); - - $connection = new class($this->getContainer(), $pool, ['host' => '127.0.0.1', 'port' => 6379], $redis) extends PhpRedisConnection { + $connection = new class($this->getContainer(), $pool, $this->standaloneConfig(['database' => 1]), $redis) extends PhpRedisConnection { public function __construct( ContainerContract $container, PoolInterface $pool, @@ -115,7 +88,7 @@ protected function createRedis(array $config): Redis } }; - $connection->setDatabase(5); + $connection->setDatabase(2); $connection->release(); } @@ -239,7 +212,9 @@ public function testReconnectBeginsWithNoTrackedWatchState(): void $redis = m::mock(Redis::class); $redis->expects('watch')->with('key')->andReturnTrue(); $redis->expects('getMode')->andReturn(Redis::ATOMIC); - $connection = new class($this->getContainer(), $pool, ['host' => '127.0.0.1', 'port' => 6379], $redis) extends PhpRedisConnection { + $redis->shouldReceive('setOption')->andReturnTrue(); + + $connection = new class($this->getContainer(), $pool, $this->standaloneConfig(), $redis) extends PhpRedisConnection { public function __construct( ContainerContract $container, PoolInterface $pool, @@ -302,7 +277,7 @@ public function testModeDetectionFailureInvalidatesAndReleasesConnection(): void $pool->expects('release')->with(m::type(RedisConnection::class)); $redis = m::mock(Redis::class); $redis->expects('getMode')->andThrow(new RuntimeException('Mode failed.')); - $connection = new class($this->getContainer(), $pool, []) extends PhpRedisConnectionStub { + $connection = new class($this->getContainer(), $pool, $this->standaloneConfig()) extends PhpRedisConnectionStub { public function isInvalidForTest(): bool { return $this->invalid; @@ -322,7 +297,7 @@ public function testDatabaseRestoreFailureInvalidatesAndReleasesConnection(): vo $redis = m::mock(Redis::class); $redis->expects('getMode')->andReturn(Redis::ATOMIC); $redis->expects('select')->with(0)->andThrow(new RuntimeException('Select failed.')); - $connection = new class($this->getContainer(), $pool, ['database' => 0]) extends PhpRedisConnectionStub { + $connection = new class($this->getContainer(), $pool, $this->standaloneConfig()) extends PhpRedisConnectionStub { public function isInvalidForTest(): bool { return $this->invalid; @@ -381,7 +356,9 @@ public function testReconnectUsesCurrentDatabaseWhenSet(): void $redis = m::mock(Redis::class); $redis->shouldReceive('select')->once()->with(2)->andReturn(true); - $connection = new class($this->getContainer(), $pool, ['host' => '127.0.0.1', 'port' => 6379, 'database' => 0], $redis) extends PhpRedisConnection { + $redis->shouldReceive('setOption')->andReturnTrue(); + + $connection = new class($this->getContainer(), $pool, $this->standaloneConfig(), $redis) extends PhpRedisConnection { public function __construct( ContainerContract $container, PoolInterface $pool, @@ -413,7 +390,8 @@ public function testSentinelResolvedMasterUsesStandaloneDataConnectionSettings() $container->shouldReceive('bound')->with('events')->andReturnFalse(); $redis = m::mock(Redis::class); $redis->expects('setOption')->with(Redis::OPT_READ_TIMEOUT, 2.5)->andReturnTrue(); - $connection = new class($container, $this->getMockedPool(), ['timeout' => 1.5, 'retry_interval' => 100, 'read_timeout' => 2.5, 'context' => ['stream' => ['tcp_nodelay' => true]], 'sentinel' => ['enabled' => true, 'nodes' => ['127.0.0.1:26379'], 'master_name' => 'primary', 'read_timeout' => 9.0, 'context' => ['ssl' => ['verify_peer' => true]]]], $redis) extends PhpRedisConnection { + $this->expectDefaultConnectionOptions($redis); + $connection = new class($container, $this->getMockedPool(), $this->sentinelConfig(['timeout' => 1.5, 'retry_interval' => 100, 'read_timeout' => 2.5, 'context' => ['stream' => ['tcp_nodelay' => true]]]), $redis) extends PhpRedisConnection { private array $createdConfig = []; public function __construct( @@ -447,75 +425,16 @@ protected function createRedis(array $config): Redis ); } - public function testConnectionConfigMergesDefaults(): void + public function testConnectionConfigIsStoredWithoutAHiddenSchema(): void { + $config = ['host' => 'redis']; $connection = new PhpRedisConnectionStub( $this->getContainer(), $this->getMockedPool(), - [ - 'host' => 'redis', - 'port' => 16379, - 'password' => 'redis', - 'database' => 0, - 'retry_interval' => 5, - 'read_timeout' => 3.0, - 'context' => [ - 'stream' => ['cafile' => 'foo-cafile', 'verify_peer' => true], - ], - 'cluster' => [ - 'enabled' => false, - 'seeds' => ['127.0.0.1:6379'], - ], - 'pool' => [ - 'min_connections' => 1, - 'max_connections' => 30, - 'connect_timeout' => 10.0, - 'wait_timeout' => 3.0, - 'heartbeat' => -1, - 'max_idle_time' => 1, - ], - ], + $config, ); - $this->assertSame( - [ - 'timeout' => 0.0, - 'retry_interval' => 5, - 'read_timeout' => 3.0, - 'cluster' => [ - 'enabled' => false, - 'seeds' => ['127.0.0.1:6379'], - ], - 'sentinel' => [ - 'enabled' => false, - 'master_name' => '', - 'nodes' => [], - 'username' => null, - 'password' => null, - 'timeout' => 0.0, - 'read_timeout' => 0.0, - 'context' => [], - ], - 'options' => [], - 'context' => [ - 'stream' => ['cafile' => 'foo-cafile', 'verify_peer' => true], - ], - 'events' => false, - 'host' => 'redis', - 'port' => 16379, - 'password' => 'redis', - 'database' => 0, - 'pool' => [ - 'min_connections' => 1, - 'max_connections' => 30, - 'connect_timeout' => 10.0, - 'wait_timeout' => 3.0, - 'heartbeat' => -1, - 'max_idle_time' => 1, - ], - ], - $connection->getConfigForTest(), - ); + $this->assertSame($config, $connection->getConfigForTest()); } public function testNormalizeContextAcceptsEverySupportedShape(): void @@ -549,6 +468,7 @@ public function testEmptyContextKeepsStandaloneConnectionPlaintext(): void $this->getContainer(), $this->getMockedPool(), [ + ...$this->standaloneConfig(), 'host' => $host, 'port' => $port, 'timeout' => 1.0, @@ -586,6 +506,7 @@ public function testNonEmptyContextEnablesTlsForStandaloneConnection(): void $this->getContainer(), $this->getMockedPool(), [ + ...$this->standaloneConfig(), 'host' => $host, 'port' => $port, 'timeout' => 1.0, @@ -610,7 +531,7 @@ public function testClusterReconnectFailureThrowsConnectionException(): void $this->expectException(ConnectionException::class); $this->expectExceptionMessage('Connection reconnect failed'); - new class($this->getContainer(), $this->getMockedPool(), ['cluster' => ['enabled' => true, 'seeds' => []], 'timeout' => 1.0, 'read_timeout' => 1.0]) extends PhpRedisClusterConnection { + new class($this->getContainer(), $this->getMockedPool(), $this->clusterConfig(['cluster' => ['enabled' => true, 'seeds' => []]])) extends PhpRedisClusterConnection { }; } @@ -1002,7 +923,9 @@ public function testLogWritesToStdoutLogger(): void $container->shouldReceive('has')->with(StdoutLoggerInterface::class)->andReturn(true); $container->shouldReceive('make')->with(StdoutLoggerInterface::class)->andReturn($logger); - $connection = new class($container, $pool, ['host' => '127.0.0.1', 'port' => 6379], $redis) extends PhpRedisConnection { + $redis->shouldReceive('setOption')->andReturnTrue(); + + $connection = new class($container, $pool, $this->standaloneConfig(), $redis) extends PhpRedisConnection { public function __construct( ContainerContract $container, PoolInterface $pool, @@ -2093,7 +2016,7 @@ public function testEvalReordersArguments(): void // C extension which tries a real connection. Instead, override callEval // to capture the arguments it receives after __call dispatches to it. $captured = []; - $connection = new class($this->getContainer(), $this->getMockedPool(), [], $captured) extends PhpRedisConnection { + $connection = new class($this->getContainer(), $this->getMockedPool(), $this->standaloneConfig(), $captured) extends PhpRedisConnection { public function __construct( ContainerContract $container, PoolInterface $pool, @@ -2134,7 +2057,7 @@ protected function callEval(string $script, int $numberOfKeys, mixed ...$argumen public function testEvalReordersMultipleArguments(): void { $captured = []; - $connection = new class($this->getContainer(), $this->getMockedPool(), [], $captured) extends PhpRedisConnection { + $connection = new class($this->getContainer(), $this->getMockedPool(), $this->standaloneConfig(), $captured) extends PhpRedisConnection { public function __construct( ContainerContract $container, PoolInterface $pool, @@ -2175,7 +2098,7 @@ protected function callEval(string $script, int $numberOfKeys, mixed ...$argumen public function testEvalWithNoKeysOrArguments(): void { $captured = []; - $connection = new class($this->getContainer(), $this->getMockedPool(), [], $captured) extends PhpRedisConnection { + $connection = new class($this->getContainer(), $this->getMockedPool(), $this->standaloneConfig(), $captured) extends PhpRedisConnection { public function __construct( ContainerContract $container, PoolInterface $pool, @@ -2318,7 +2241,9 @@ public function testReconnectSetsSerializerOption(): void ->once() ->with(Redis::OPT_SERIALIZER, Redis::SERIALIZER_PHP); - new class($this->getContainer(), $pool, ['host' => '127.0.0.1', 'port' => 6379, 'options' => ['serializer' => Redis::SERIALIZER_PHP]], $redis) extends PhpRedisConnection { + $this->expectDefaultConnectionOptions($redis); + + new class($this->getContainer(), $pool, $this->standaloneConfig(['options' => ['serializer' => Redis::SERIALIZER_PHP]]), $redis) extends PhpRedisConnection { public function __construct( ContainerContract $container, PoolInterface $pool, @@ -2343,7 +2268,9 @@ public function testReconnectSetsPrefixOption(): void ->once() ->with(Redis::OPT_PREFIX, 'myapp:'); - new class($this->getContainer(), $pool, ['host' => '127.0.0.1', 'port' => 6379, 'options' => ['prefix' => 'myapp:']], $redis) extends PhpRedisConnection { + $this->expectDefaultConnectionOptions($redis); + + new class($this->getContainer(), $pool, $this->standaloneConfig(['options' => ['prefix' => 'myapp:']]), $redis) extends PhpRedisConnection { public function __construct( ContainerContract $container, PoolInterface $pool, @@ -2372,7 +2299,9 @@ public function testReconnectSetsPackIgnoreNumbersOnStandaloneConnection(): void ->with(Redis::OPT_PACK_IGNORE_NUMBERS, true) ->andReturnTrue(); - new class($this->getContainer(), $pool, ['host' => '127.0.0.1', 'port' => 6379, 'options' => ['pack_ignore_numbers' => true]], $redis) extends PhpRedisConnection { + $this->expectDefaultConnectionOptions($redis); + + new class($this->getContainer(), $pool, $this->standaloneConfig(['options' => ['pack_ignore_numbers' => true]]), $redis) extends PhpRedisConnection { public function __construct( ContainerContract $container, PoolInterface $pool, @@ -2409,7 +2338,7 @@ public function testReconnectSetsConnectionLevelPhpRedisOptions(): void ->once() ->with(Redis::OPT_BACKOFF_CAP, 2000); - new class($this->getContainer(), $pool, ['host' => '127.0.0.1', 'port' => 6379, 'read_timeout' => 5.0, 'max_retries' => 4, 'backoff_algorithm' => 'constant', 'backoff_base' => 200, 'backoff_cap' => 2000], $redis) extends PhpRedisConnection { + new class($this->getContainer(), $pool, $this->standaloneConfig(['read_timeout' => 5.0, 'max_retries' => 4, 'backoff_algorithm' => 'constant', 'backoff_base' => 200, 'backoff_cap' => 2000]), $redis) extends PhpRedisConnection { public function __construct( ContainerContract $container, PoolInterface $pool, @@ -2430,9 +2359,9 @@ public function testReconnectDoesNotSetReadTimeoutOptionWhenEmpty(): void { $pool = $this->getMockedPool(); $redis = m::mock(Redis::class); - $redis->shouldReceive('setOption')->never(); + $this->expectDefaultConnectionOptions($redis); - new class($this->getContainer(), $pool, ['host' => '127.0.0.1', 'port' => 6379, 'read_timeout' => 0.0], $redis) extends PhpRedisConnection { + new class($this->getContainer(), $pool, $this->standaloneConfig(['read_timeout' => 0.0]), $redis) extends PhpRedisConnection { public function __construct( ContainerContract $container, PoolInterface $pool, @@ -2457,7 +2386,9 @@ public function testReconnectSetsNumericBackoffAlgorithmAsIs(): void ->once() ->with(Redis::OPT_BACKOFF_ALGORITHM, Redis::BACKOFF_ALGORITHM_DEFAULT); - new class($this->getContainer(), $pool, ['host' => '127.0.0.1', 'port' => 6379, 'backoff_algorithm' => Redis::BACKOFF_ALGORITHM_DEFAULT], $redis) extends PhpRedisConnection { + $redis->shouldReceive('setOption')->andReturnTrue(); + + new class($this->getContainer(), $pool, $this->standaloneConfig(['backoff_algorithm' => Redis::BACKOFF_ALGORITHM_DEFAULT]), $redis) extends PhpRedisConnection { public function __construct( ContainerContract $container, PoolInterface $pool, @@ -2482,7 +2413,9 @@ public function testReconnectThrowsOnUnknownBackoffAlgorithm(): void $this->expectException(\Hypervel\Redis\Exceptions\InvalidRedisOptionException::class); $this->expectExceptionMessage('Algorithm [bogus] is not a valid PhpRedis backoff algorithm.'); - new class($this->getContainer(), $pool, ['host' => '127.0.0.1', 'port' => 6379, 'backoff_algorithm' => 'bogus'], $redis) extends PhpRedisConnection { + $redis->shouldReceive('setOption')->andReturnTrue(); + + new class($this->getContainer(), $pool, $this->standaloneConfig(['backoff_algorithm' => 'bogus']), $redis) extends PhpRedisConnection { public function __construct( ContainerContract $container, PoolInterface $pool, @@ -2507,7 +2440,9 @@ public function testReconnectThrowsOnUnknownOption(): void $this->expectException(\Hypervel\Redis\Exceptions\InvalidRedisOptionException::class); $this->expectExceptionMessage('The redis option key `bogus` is invalid.'); - new class($this->getContainer(), $pool, ['host' => '127.0.0.1', 'port' => 6379, 'options' => ['bogus' => 'value']], $redis) extends PhpRedisConnection { + $redis->shouldReceive('setOption')->andReturnTrue(); + + new class($this->getContainer(), $pool, $this->standaloneConfig(['options' => ['bogus' => 'value']]), $redis) extends PhpRedisConnection { public function __construct( ContainerContract $container, PoolInterface $pool, @@ -2534,7 +2469,9 @@ public function testReconnectSetsNumericOptions(): void ->once() ->with(Redis::OPT_SERIALIZER, Redis::SERIALIZER_JSON); - new class($this->getContainer(), $pool, ['host' => '127.0.0.1', 'port' => 6379, 'options' => [Redis::OPT_SERIALIZER => Redis::SERIALIZER_JSON]], $redis) extends PhpRedisConnection { + $this->expectDefaultConnectionOptions($redis); + + new class($this->getContainer(), $pool, $this->standaloneConfig(['options' => [Redis::OPT_SERIALIZER => Redis::SERIALIZER_JSON]]), $redis) extends PhpRedisConnection { public function __construct( ContainerContract $container, PoolInterface $pool, @@ -2559,7 +2496,9 @@ public function testReconnectAuthenticatesWhenAuthConfigured(): void ->once() ->with('secret'); - new class($this->getContainer(), $pool, ['host' => '127.0.0.1', 'port' => 6379, 'password' => 'secret'], $redis) extends PhpRedisConnection { + $this->expectDefaultConnectionOptions($redis); + + new class($this->getContainer(), $pool, $this->standaloneConfig(['password' => 'secret']), $redis) extends PhpRedisConnection { public function __construct( ContainerContract $container, PoolInterface $pool, @@ -2582,7 +2521,9 @@ public function testReconnectDoesNotAuthenticateWhenAuthEmpty(): void $redis = m::mock(Redis::class); $redis->shouldNotReceive('auth'); - new class($this->getContainer(), $pool, ['host' => '127.0.0.1', 'port' => 6379, 'password' => ''], $redis) extends PhpRedisConnection { + $this->expectDefaultConnectionOptions($redis); + + new class($this->getContainer(), $pool, $this->standaloneConfig(['password' => '']), $redis) extends PhpRedisConnection { public function __construct( ContainerContract $container, PoolInterface $pool, @@ -2670,7 +2611,9 @@ public function testReconnectClearsInvalidState(): void $redis = m::mock(Redis::class); $redis->shouldReceive('select')->andReturn(true); - $connection = new class($this->getContainer(), $pool, ['host' => '127.0.0.1', 'port' => 6379, 'database' => 1], $redis) extends PhpRedisConnection { + $redis->shouldReceive('setOption')->andReturnTrue(); + + $connection = new class($this->getContainer(), $pool, $this->standaloneConfig(['database' => 1]), $redis) extends PhpRedisConnection { public function __construct( ContainerContract $container, PoolInterface $pool, @@ -2707,7 +2650,9 @@ public function testInvalidStateIsNotMaskedByFreshReleaseTime(): void $redis = m::mock(Redis::class); - $connection = new class($this->getContainer(), $pool, ['host' => '127.0.0.1', 'port' => 6379], $redis) extends PhpRedisConnection { + $redis->shouldReceive('setOption')->andReturnTrue(); + + $connection = new class($this->getContainer(), $pool, $this->standaloneConfig(), $redis) extends PhpRedisConnection { public function __construct( ContainerContract $container, PoolInterface $pool, @@ -2740,7 +2685,9 @@ public function testCheckDoesNotResetActivityTimestamp(): void $pool->shouldReceive('getOption')->andReturn(new PoolOption(maxIdleTime: 60.0)); $redis = m::mock(Redis::class); - $connection = new class($this->getContainer(), $pool, ['host' => '127.0.0.1', 'port' => 6379], $redis) extends PhpRedisConnection { + $redis->shouldReceive('setOption')->andReturnTrue(); + + $connection = new class($this->getContainer(), $pool, $this->standaloneConfig(), $redis) extends PhpRedisConnection { public function __construct( ContainerContract $container, PoolInterface $pool, @@ -2791,12 +2738,111 @@ public function testScanWithArrayOptions(): void $this->assertEquals([0, ['key1', 'key2']], $result); } + /** + * Create a complete standalone Redis connection record. + */ + protected function standaloneConfig(array $overrides = []): array + { + return array_replace($this->baseConnectionConfig(), [ + 'url' => null, + 'host' => '127.0.0.1', + 'port' => 6379, + 'database' => 0, + 'name' => null, + 'retry_interval' => 0, + ], $overrides); + } + + /** + * Create a complete Sentinel Redis connection record. + */ + protected function sentinelConfig(array $overrides = []): array + { + return array_replace($this->baseConnectionConfig(), [ + 'database' => 0, + 'name' => null, + 'retry_interval' => 0, + 'sentinel' => [ + 'enabled' => true, + 'master_name' => 'primary', + 'nodes' => ['127.0.0.1:26379'], + 'username' => null, + 'password' => null, + 'timeout' => 1.0, + 'read_timeout' => 1.0, + 'context' => [], + ], + ], $overrides); + } + + /** + * Create a complete Cluster Redis connection record. + */ + protected function clusterConfig(array $overrides = []): array + { + return array_replace($this->baseConnectionConfig(), [ + 'scheme' => 'tcp', + 'cluster' => [ + 'enabled' => true, + 'seeds' => ['tcp://127.0.0.1:7000'], + ], + ], $overrides); + } + + /** + * Create the members shared by every Redis connection topology. + */ + protected function baseConnectionConfig(): array + { + return [ + 'scheme' => null, + 'username' => null, + 'password' => null, + 'timeout' => 1.0, + 'read_timeout' => 0.0, + 'context' => [], + 'options' => [], + 'prefix' => null, + 'events' => false, + 'max_retries' => 3, + 'backoff_algorithm' => 'decorrelated_jitter', + 'backoff_base' => 100, + 'backoff_cap' => 1000, + 'pool' => [ + 'min_connections' => 1, + 'max_connections' => 10, + 'connect_timeout' => 10.0, + 'wait_timeout' => 3.0, + 'heartbeat' => -1.0, + 'heartbeat_timeout' => 1.0, + 'max_idle_time' => 60.0, + 'max_lifetime' => -1.0, + ], + ]; + } + + /** + * Expect the default connection-level phpredis options. + */ + protected function expectDefaultConnectionOptions(Redis $redis): void + { + $redis->expects('setOption')->with(Redis::OPT_MAX_RETRIES, 3)->andReturnTrue(); + $redis->expects('setOption') + ->with(Redis::OPT_BACKOFF_ALGORITHM, Redis::BACKOFF_ALGORITHM_DECORRELATED_JITTER) + ->andReturnTrue(); + $redis->expects('setOption')->with(Redis::OPT_BACKOFF_BASE, 100)->andReturnTrue(); + $redis->expects('setOption')->with(Redis::OPT_BACKOFF_CAP, 1000)->andReturnTrue(); + } + + /** + * Create a Redis connection test double. + */ protected function mockRedisConnection(?ContainerContract $container = null, ?PoolInterface $pool = null, array $options = [], bool $transform = false): RedisConnection { $connection = new PhpRedisConnectionStub( $container ?? $this->getContainer(), $pool ?? $this->getMockedPool(), - $options + $this->standaloneConfig($options) ); if ($transform) { diff --git a/tests/Redis/RedisManagerTest.php b/tests/Redis/RedisManagerTest.php index 708fe67d8..463255d14 100644 --- a/tests/Redis/RedisManagerTest.php +++ b/tests/Redis/RedisManagerTest.php @@ -323,12 +323,13 @@ private function createManager( */ private function createRedisConfig(array $validNames): RedisConfig { - $configData = []; + $configData = ['options' => []]; foreach ($validNames as $name) { $configData[$name] = [ 'host' => 'localhost', 'port' => 6379, 'database' => 0, + 'options' => [], ]; } diff --git a/tests/Redis/RedisPoolHeartbeatTest.php b/tests/Redis/RedisPoolHeartbeatTest.php index 7f6b394cb..9a20af1ca 100644 --- a/tests/Redis/RedisPoolHeartbeatTest.php +++ b/tests/Redis/RedisPoolHeartbeatTest.php @@ -472,10 +472,11 @@ public function testClusterHeartbeatChecksAllMasters(): void */ protected function createPool(array $poolOptions = [], string $poolClass = InspectableRedisPool::class, array $config = []): InspectableRedisPool { - $connectionConfig = array_replace_recursive([ + $connectionConfig = array_replace([ 'host' => '127.0.0.1', 'port' => 6379, 'database' => 0, + 'timeout' => null, 'cluster' => ['enabled' => false], 'pool' => [ 'min_connections' => 1, diff --git a/tests/Redis/RedisPoolTest.php b/tests/Redis/RedisPoolTest.php index 93683cdef..f927dab1a 100644 --- a/tests/Redis/RedisPoolTest.php +++ b/tests/Redis/RedisPoolTest.php @@ -24,6 +24,7 @@ public function testPoolConnectTimeoutConfiguresTheNativeRedisTimeout(): void 'host' => 'redis', 'port' => 16379, 'database' => 0, + 'timeout' => null, 'pool' => [ 'min_connections' => 1, 'max_connections' => 30, @@ -70,11 +71,14 @@ public function testEventOverrideDoesNotRetrofitExistingPoolConfiguration(): voi $redisConfig = new RedisConfig(new Repository([ 'database' => [ 'redis' => [ + 'options' => [], 'default' => [ 'host' => 'redis', 'port' => 16379, 'database' => 0, + 'timeout' => null, 'events' => false, + 'options' => [], 'pool' => [ 'min_connections' => 1, 'max_connections' => 30, @@ -106,6 +110,7 @@ public function testLowFrequencyFlushClosesIdleConnections(): void 'host' => 'redis', 'port' => 16379, 'database' => 0, + 'timeout' => null, 'pool' => [ 'min_connections' => 1, 'max_connections' => 30, @@ -147,6 +152,8 @@ public function testLowFrequencyFlushClosesIdleConnections(): void } /** + * Mock a container with the given Redis configuration. + * * @param array $connectionConfig */ private function mockContainerWithRedisConfig(array $connectionConfig): m\MockInterface|Container diff --git a/tests/Redis/RedisProxyTest.php b/tests/Redis/RedisProxyTest.php index b1b85ecbb..92187765d 100644 --- a/tests/Redis/RedisProxyTest.php +++ b/tests/Redis/RedisProxyTest.php @@ -999,12 +999,12 @@ public function testSubscriberUsesTheCompleteStandaloneConfiguration(): void fread($client, 1); }); $pool = m::mock(RedisPool::class); - $pool->expects('getConfig')->andReturn([ + $pool->expects('getConfig')->andReturn($this->standaloneConfig([ 'host' => $server->endpoint(), 'port' => 6379, 'timeout' => 2.5, 'options' => ['prefix' => 'app:'], - ]); + ])); $pool->shouldNotReceive('get'); $factory = m::mock(PoolFactory::class); $factory->expects('getPool')->with('default')->andReturn($pool); @@ -1039,13 +1039,12 @@ public function testSubscriberResolvesSentinelMasterFreshWithConnectionCredentia [$firstHost, $firstPort] = $servers[0]->hostAndPort(); [$secondHost, $secondPort] = $servers[1]->hostAndPort(); - $config = [ - 'sentinel' => ['enabled' => true], + $config = $this->sentinelConfig([ 'username' => '0', 'password' => '0', 'timeout' => 1.0, 'options' => ['prefix' => 'sentinel:'], - ]; + ]); $pool = m::mock(RedisPool::class); $pool->expects('getConfig')->twice()->andReturn($config); $pool->shouldNotReceive('get'); @@ -1086,7 +1085,7 @@ public function testClusterSubscriberUsesConnectionTransportAndReleasesDiscovery fread($client, 1); }); [$host, $port] = $server->hostAndPort(); - $config = [ + $config = $this->clusterConfig([ 'scheme' => 'tcp', 'cluster' => [ 'enabled' => true, @@ -1095,7 +1094,7 @@ public function testClusterSubscriberUsesConnectionTransportAndReleasesDiscovery 'context' => [], 'timeout' => 0.1, 'options' => ['prefix' => 'cluster:'], - ]; + ]); $connection = m::mock(PhpRedisClusterConnection::class); $connection->expects('getConnection')->andReturnSelf(); $connection->expects('masters')->andReturn([ @@ -1153,7 +1152,7 @@ public function testClusterSubscriberUsesTlsConnectionTransportForMaster(): void fread($client, 1); }); [$host, $port] = $server->hostAndPort(); - $config = [ + $config = $this->clusterConfig([ 'scheme' => 'tls', 'context' => $clientOptions, 'cluster' => [ @@ -1161,7 +1160,7 @@ public function testClusterSubscriberUsesTlsConnectionTransportForMaster(): void 'seeds' => ['tls://127.0.0.1:1'], ], 'timeout' => 0.1, - ]; + ]); $connection = m::mock(PhpRedisClusterConnection::class); $connection->expects('getConnection')->andReturnSelf(); $connection->expects('masters')->andReturn([[$host, $port]]); @@ -1191,13 +1190,13 @@ public function testClusterSubscriberUsesTlsConnectionTransportForMaster(): void public function testClusterSubscriberAggregatesEndpointFailures(): void { - $config = [ + $config = $this->clusterConfig([ 'cluster' => [ 'enabled' => true, 'seeds' => ['tcp://127.0.0.1:1'], ], 'timeout' => 0.01, - ]; + ]); $connection = m::mock(PhpRedisClusterConnection::class); $connection->expects('getConnection')->andReturnSelf(); $connection->expects('masters')->andReturn([ @@ -1480,6 +1479,92 @@ private function createMockRedisConnection( return $mockRedisConnection; } + /** + * Create a complete standalone Redis connection record. + */ + private function standaloneConfig(array $overrides = []): array + { + return array_replace($this->baseConnectionConfig(), [ + 'url' => null, + 'host' => '127.0.0.1', + 'port' => 6379, + 'database' => 0, + 'name' => null, + 'retry_interval' => 0, + ], $overrides); + } + + /** + * Create a complete Sentinel Redis connection record. + */ + private function sentinelConfig(array $overrides = []): array + { + return array_replace($this->baseConnectionConfig(), [ + 'database' => 0, + 'name' => null, + 'retry_interval' => 0, + 'sentinel' => [ + 'enabled' => true, + 'master_name' => 'primary', + 'nodes' => ['tcp://127.0.0.1:26379'], + 'username' => null, + 'password' => null, + 'timeout' => 1.0, + 'read_timeout' => 1.0, + 'context' => [], + ], + ], $overrides); + } + + /** + * Create a complete Cluster Redis connection record. + */ + private function clusterConfig(array $overrides = []): array + { + return array_replace($this->baseConnectionConfig(), [ + 'scheme' => 'tcp', + 'cluster' => [ + 'enabled' => true, + 'seeds' => ['tcp://127.0.0.1:7000'], + ], + ], $overrides); + } + + /** + * Create the members shared by every Redis connection topology. + */ + private function baseConnectionConfig(): array + { + return [ + 'scheme' => null, + 'username' => null, + 'password' => null, + 'timeout' => 1.0, + 'read_timeout' => 0.0, + 'context' => [], + 'options' => [], + 'prefix' => null, + 'events' => false, + 'max_retries' => 3, + 'backoff_algorithm' => 'decorrelated_jitter', + 'backoff_base' => 100, + 'backoff_cap' => 1000, + 'pool' => [ + 'min_connections' => 1, + 'max_connections' => 10, + 'connect_timeout' => 10.0, + 'wait_timeout' => 3.0, + 'heartbeat' => -1.0, + 'heartbeat_timeout' => 1.0, + 'max_idle_time' => 60.0, + 'max_lifetime' => -1.0, + ], + ]; + } + + /** + * Get a mocked Sentinel factory. + */ private function sentinelFactory(): RedisSentinelFactory { return m::mock(RedisSentinelFactory::class); diff --git a/tests/Redis/RedisSentinelFactoryTest.php b/tests/Redis/RedisSentinelFactoryTest.php index 75b8859f5..496f27e21 100644 --- a/tests/Redis/RedisSentinelFactoryTest.php +++ b/tests/Redis/RedisSentinelFactoryTest.php @@ -39,16 +39,13 @@ public function create(array $options = []): RedisSentinel } }; - $master = $factory->resolveMaster([ - 'sentinel' => [ - 'nodes' => ['tcp://127.0.0.1:26379', 'tcp://127.0.0.2:26380'], - 'master_name' => 'primary', - 'username' => 'sentinel-user', - 'password' => '0', - 'timeout' => 2.5, - 'read_timeout' => 1.5, - ], - ]); + $master = $factory->resolveMaster($this->sentinelConfig([ + 'nodes' => ['tcp://127.0.0.1:26379', 'tcp://127.0.0.2:26380'], + 'username' => 'sentinel-user', + 'password' => '0', + 'timeout' => 2.5, + 'read_timeout' => 1.5, + ])); $this->assertSame(['10.0.0.1', 6380], $master); $this->assertCount(2, $factory->createdWith); @@ -83,13 +80,9 @@ public function create(array $options = []): RedisSentinel } }; - $factory->resolveMaster([ - 'sentinel' => [ - 'nodes' => ['127.0.0.1:26379'], - 'master_name' => 'primary', - 'password' => 'secret', - ], - ]); + $factory->resolveMaster($this->sentinelConfig([ + 'password' => 'secret', + ])); $this->assertSame('secret', $factory->createdWith[0]['auth']); } @@ -116,12 +109,9 @@ public function create(array $options = []): RedisSentinel } }; - $factory->resolveMaster([ - 'sentinel' => [ - 'nodes' => [$node], - 'master_name' => 'primary', - ], - ]); + $factory->resolveMaster($this->sentinelConfig([ + 'nodes' => [$node], + ])); $this->assertSame($host, $factory->createdWith[0]['host']); $this->assertSame(26379, $factory->createdWith[0]['port']); @@ -157,13 +147,9 @@ public function create(array $options = []): RedisSentinel } }; - $factory->resolveMaster([ - 'sentinel' => [ - 'nodes' => ['127.0.0.1:26379'], - 'master_name' => 'primary', - 'context' => $context, - ], - ]); + $factory->resolveMaster($this->sentinelConfig([ + 'context' => $context, + ])); if ($expected === null) { $this->assertArrayNotHasKey('ssl', $factory->createdWith[0]); @@ -189,15 +175,12 @@ public function testResolveMasterRejectsUnsupportedNodeComponents(): void $factory = new RedisSentinelFactory; try { - $factory->resolveMaster([ - 'sentinel' => [ - 'nodes' => [ - 'user:password@127.0.0.1:26379', - 'tcp://127.0.0.1:26379/path', - ], - 'master_name' => 'primary', + $factory->resolveMaster($this->sentinelConfig([ + 'nodes' => [ + 'user:password@127.0.0.1:26379', + 'tcp://127.0.0.1:26379/path', ], - ]); + ])); $this->fail('Expected Sentinel resolution to fail.'); } catch (InvalidRedisConnectionException $exception) { $this->assertStringContainsString( @@ -216,12 +199,9 @@ public function testResolveMasterRejectsUnbracketedIpv6Nodes(): void $factory = new RedisSentinelFactory; try { - $factory->resolveMaster([ - 'sentinel' => [ - 'nodes' => ['fe80::1:2637', '::1'], - 'master_name' => 'primary', - ], - ]); + $factory->resolveMaster($this->sentinelConfig([ + 'nodes' => ['fe80::1:2637', '::1'], + ])); $this->fail('Expected Sentinel resolution to fail.'); } catch (InvalidRedisConnectionException $exception) { $this->assertStringContainsString( @@ -253,12 +233,9 @@ public function create(array $options = []): RedisSentinel }; try { - $factory->resolveMaster([ - 'sentinel' => [ - 'nodes' => ['invalid-node', 'tcp://127.0.0.1:26379'], - 'master_name' => 'primary', - ], - ]); + $factory->resolveMaster($this->sentinelConfig([ + 'nodes' => ['invalid-node', 'tcp://127.0.0.1:26379'], + ])); $this->fail('Expected Sentinel resolution to fail.'); } catch (InvalidRedisConnectionException $exception) { $this->assertStringContainsString('[invalid-node]: invalid node', $exception->getMessage()); @@ -289,11 +266,25 @@ public function create(array $options = []): RedisSentinel '[tcp://127.0.0.1:26379]: master was not resolved' ); - $factory->resolveMaster([ - 'sentinel' => [ + $factory->resolveMaster($this->sentinelConfig()); + } + + /** + * Create a complete Sentinel topology block. + */ + private function sentinelConfig(array $overrides = []): array + { + return [ + 'sentinel' => array_replace([ + 'enabled' => true, 'nodes' => ['tcp://127.0.0.1:26379'], 'master_name' => 'primary', - ], - ]); + 'username' => null, + 'password' => null, + 'timeout' => 0.0, + 'read_timeout' => 0.0, + 'context' => [], + ], $overrides), + ]; } } From f81e4c13302233480f519066a19a501a1d40bc23 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:41:20 +0000 Subject: [PATCH 011/109] cache: honor Redis store prefix inheritance in benchmarks Read the required application environment through typed config and derive benchmark recovery commands from the complete selected cache-store record. An explicit null store prefix now inherits the shared cache prefix instead of relying on a duplicate getter fallback. Add a focused regression proving the generated Redis cleanup guidance uses the inherited prefix. --- .../src/Redis/Console/BenchmarkCommand.php | 11 +++++----- .../Redis/Console/BenchmarkCommandTest.php | 22 +++++++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/cache/src/Redis/Console/BenchmarkCommand.php b/src/cache/src/Redis/Console/BenchmarkCommand.php index f7fb4cb8f..4ecb1ac7f 100644 --- a/src/cache/src/Redis/Console/BenchmarkCommand.php +++ b/src/cache/src/Redis/Console/BenchmarkCommand.php @@ -283,7 +283,7 @@ protected function confirmSafeToRun(): bool } $config = $this->hypervel->make('config'); - $env = $config->string('app.env', 'production'); + $env = $config->string('app.env'); $scale = $this->option('scale'); $this->warn('WARNING: This benchmark will put EXTREME load on your Redis instance'); @@ -567,10 +567,11 @@ protected function displayMemoryError(BenchmarkMemoryException $e): void $this->line(' php artisan cache:clear ' . $this->storeName . ''); $this->newLine(); $this->line(' Option 2 - Clear only benchmark keys (preserves other cache):'); - $cachePrefix = $config->string( - "cache.stores.{$this->storeName}.prefix", - $config->string('cache.prefix'), - ); + $prefixKey = "cache.stores.{$this->storeName}.prefix"; + $storePrefix = $config->array("cache.stores.{$this->storeName}")['prefix']; + $cachePrefix = $storePrefix === null + ? $config->string('cache.prefix') + : $config->string($prefixKey); $this->line(' redis-cli KEYS "' . $cachePrefix . BenchmarkContext::KEY_PREFIX . '*" | xargs redis-cli DEL'); } diff --git a/tests/Cache/Redis/Console/BenchmarkCommandTest.php b/tests/Cache/Redis/Console/BenchmarkCommandTest.php index 1664a4d51..d16e6e089 100644 --- a/tests/Cache/Redis/Console/BenchmarkCommandTest.php +++ b/tests/Cache/Redis/Console/BenchmarkCommandTest.php @@ -5,9 +5,11 @@ namespace Hypervel\Tests\Cache\Redis\Console; use Hypervel\Cache\Redis\Console\BenchmarkCommand; +use Hypervel\Cache\Redis\Exceptions\BenchmarkMemoryException; use Hypervel\Cache\RedisStore; use Hypervel\Cache\Repository; use Hypervel\Console\Command; +use Hypervel\Console\OutputStyle; use Hypervel\Contracts\Cache\Factory as CacheContract; use Hypervel\Testbench\TestCase; use Mockery as m; @@ -82,6 +84,19 @@ public function testSetupFailsGracefullyWhenNoRedisStoreCanBeDetected(): void ); } + public function testMemoryRecoveryGuidanceInheritsTheSharedPrefix(): void + { + config()->set('cache.prefix', 'shared:'); + config()->set('cache.stores.redis.prefix', null); + + $command = $this->createCommand(); + $output = new BufferedOutput; + $command->setOutput(new OutputStyle(new ArrayInput([]), $output)); + $command->exposedDisplayMemoryError(new BenchmarkMemoryException(1, 2, 50), 'redis'); + + $this->assertStringContainsString('redis-cli KEYS "shared:', $output->fetch()); + } + private function mockCacheStore(string $name): void { $store = m::mock(RedisStore::class); @@ -116,4 +131,11 @@ public function storeName(): string { return $this->storeName; } + + public function exposedDisplayMemoryError(BenchmarkMemoryException $exception, string $storeName): void + { + $this->storeName = $storeName; + + parent::displayMemoryError($exception); + } } From 4eb3bc49d1fdce182b3f76e7a34c03cc0b44018f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:41:28 +0000 Subject: [PATCH 012/109] session: type required settings and preserve nullable stores Use typed access for required session configuration while keeping nullable connection, authentication provider, domain, and lock-store behavior explicit. Ensure blocked requests pass a null block store through to the cache manager so its default-store contract remains intact. Extend manager, middleware, configuration, and database-backed lifecycle coverage for the shipped record and supported null branches. --- .../Sqlite/UserSessionLifecycleTest.php | 10 +++++ tests/Session/Middleware/StartSessionTest.php | 42 +++++++++++++++++++ tests/Session/SessionConfigTest.php | 8 +--- tests/Session/SessionManagerTest.php | 14 +++++++ 4 files changed, 67 insertions(+), 7 deletions(-) diff --git a/tests/Integration/Session/Database/Sqlite/UserSessionLifecycleTest.php b/tests/Integration/Session/Database/Sqlite/UserSessionLifecycleTest.php index 0fabcf31d..680ff2bf0 100644 --- a/tests/Integration/Session/Database/Sqlite/UserSessionLifecycleTest.php +++ b/tests/Integration/Session/Database/Sqlite/UserSessionLifecycleTest.php @@ -36,10 +36,20 @@ protected function defineEnvironment(ApplicationContract $app): void 'auth.guards.admin' => [ 'driver' => 'session', 'provider' => 'admins', + 'passwords' => null, + 'password_timeout' => null, + 'remember' => null, ], 'auth.providers.admins' => [ 'driver' => 'eloquent', 'model' => User::class, + 'cache' => [ + 'enabled' => false, + 'store' => null, + 'ttl' => 300, + 'prefix' => 'auth_users', + 'tags' => null, + ], ], 'auth.providers.users.model' => User::class, 'hashing.bcrypt.rounds' => 4, diff --git a/tests/Session/Middleware/StartSessionTest.php b/tests/Session/Middleware/StartSessionTest.php index 92e595fbb..40ae5cbb2 100644 --- a/tests/Session/Middleware/StartSessionTest.php +++ b/tests/Session/Middleware/StartSessionTest.php @@ -303,6 +303,40 @@ public function testBlockingPreservesRequestFailureWhenReleaseAlsoFails(): void $this->assertTrue($lock->released); } + public function testNullBlockStoreUsesTheDefaultCacheStore(): void + { + $request = Request::create('/'); + $request->setRouteResolver(fn (): Route => (new Route('GET', '/', fn () => new Response))->block(10, 0)); + $manager = m::mock(SessionManager::class); + $cache = m::mock(CacheFactoryContract::class); + $session = m::mock(Session::class); + $store = m::mock(Repository::class, LockProvider::class); + $lock = m::mock(Lock::class); + $middleware = new StartSessionRespondingMiddleware( + $manager, + $cache, + m::mock(ExceptionHandlerContract::class), + ); + + $manager->shouldReceive('blockDriver')->once()->andReturnNull(); + $cache->shouldReceive('store')->once()->with(null)->andReturn($store); + $session->shouldReceive('getId')->once()->andReturn('session-id'); + $store->shouldReceive('lock')->once()->with('session:session-id', 10)->andReturn($lock); + $lock->shouldReceive('betweenBlockedAttemptsSleepFor')->once()->with(50)->andReturnSelf(); + $lock->shouldReceive('block') + ->once() + ->with(0, m::type(Closure::class)) + ->andReturnUsing(fn (int $seconds, Closure $callback): Response => $callback()); + + $response = (new ClassInvoker($middleware))->handleRequestWhileBlocking( + $request, + $session, + fn () => new Response, + ); + + $this->assertSame(200, $response->getStatusCode()); + } + public function testBlockingTimeoutDoesNotReleaseUnacquiredLock(): void { $request = Request::create('/'); @@ -376,6 +410,14 @@ protected function handleStatefulRequest(Request $request, Session $session, Clo } } +class StartSessionRespondingMiddleware extends StartSession +{ + protected function handleStatefulRequest(Request $request, Session $session, Closure $next): Response + { + return $next($request); + } +} + class StartSessionFailingReleaseLock extends Lock { public bool $released = false; diff --git a/tests/Session/SessionConfigTest.php b/tests/Session/SessionConfigTest.php index a48128911..e8e3cb2be 100644 --- a/tests/Session/SessionConfigTest.php +++ b/tests/Session/SessionConfigTest.php @@ -4,10 +4,8 @@ namespace Hypervel\Tests\Session; -use Hypervel\Container\Container; -use Hypervel\Foundation\Application; use Hypervel\Support\Env; -use Hypervel\Tests\TestCase; +use Hypervel\Testbench\TestCase; class SessionConfigTest extends TestCase { @@ -24,7 +22,6 @@ public function testBooleanOptionsAreLoadedAsBooleansFromEnvironment(): void try { Env::flushRepository(); - new Application(dirname(__DIR__, 2)); $config = require dirname(__DIR__, 2) . '/src/foundation/config/session.php'; @@ -37,7 +34,6 @@ public function testBooleanOptionsAreLoadedAsBooleansFromEnvironment(): void } finally { $this->restoreEnvironmentVariables($originalValues); Env::flushRepository(); - Container::setInstance(null); } } @@ -56,7 +52,6 @@ public function testSessionConfigurationDeclaresCanonicalDefaults(): void try { Env::flushRepository(); - new Application(dirname(__DIR__, 2)); $config = require dirname(__DIR__, 2) . '/src/foundation/config/session.php'; @@ -71,7 +66,6 @@ public function testSessionConfigurationDeclaresCanonicalDefaults(): void } finally { $this->restoreEnvironmentVariables($originalValues); Env::flushRepository(); - Container::setInstance(null); } } diff --git a/tests/Session/SessionManagerTest.php b/tests/Session/SessionManagerTest.php index 3c35ebc33..f54d30745 100644 --- a/tests/Session/SessionManagerTest.php +++ b/tests/Session/SessionManagerTest.php @@ -242,6 +242,13 @@ public function testForUserRejectsAModelFromAnotherEloquentProvider(): void Container::getInstance()->make('config')->set('auth.providers.users', [ 'driver' => 'eloquent', 'model' => SessionManagerUserStub::class, + 'cache' => [ + 'enabled' => false, + 'store' => null, + 'ttl' => 300, + 'prefix' => 'auth_users', + 'tags' => null, + ], ]); $this->expectException(InvalidArgumentException::class); @@ -259,6 +266,13 @@ public function testForUserAcceptsAModelFromTheSelectedEloquentProvider(): void Container::getInstance()->make('config')->set('auth.providers.users', [ 'driver' => 'eloquent', 'model' => SessionManagerUserStub::class, + 'cache' => [ + 'enabled' => false, + 'store' => null, + 'ttl' => 300, + 'prefix' => 'auth_users', + 'tags' => null, + ], ]); $user = new SessionManagerUserStub; $user->setAttribute('id', 42); From 887549c428eb3344a17952863f7f7b6fa2386441 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:41:41 +0000 Subject: [PATCH 013/109] hashing: read the configured driver and options by type Resolve the required hashing driver and option records through typed configuration instead of carrying manager-level defaults. Keep the public hasher constructors' own algorithm defaults for direct construction, avoiding a false requirement that runtime option arrays be complete. Adjust service-provider and hasher coverage to distinguish named configuration from direct-construction behavior. --- src/hashing/src/HashManager.php | 8 ++++---- tests/Hashing/HasherTest.php | 15 --------------- tests/Hashing/HashingServiceProviderTest.php | 13 +++++++++++-- 3 files changed, 15 insertions(+), 21 deletions(-) diff --git a/src/hashing/src/HashManager.php b/src/hashing/src/HashManager.php index 7b3734567..05f2d36ff 100644 --- a/src/hashing/src/HashManager.php +++ b/src/hashing/src/HashManager.php @@ -18,7 +18,7 @@ class HashManager extends Manager implements Hasher */ public function createBcryptDriver(): BcryptHasher { - return new BcryptHasher($this->config->array('hashing.bcrypt', [])); + return new BcryptHasher($this->config->array('hashing.bcrypt')); } /** @@ -26,7 +26,7 @@ public function createBcryptDriver(): BcryptHasher */ public function createArgonDriver(): ArgonHasher { - return new ArgonHasher($this->config->array('hashing.argon', [])); + return new ArgonHasher($this->config->array('hashing.argon')); } /** @@ -34,7 +34,7 @@ public function createArgonDriver(): ArgonHasher */ public function createArgon2idDriver(): Argon2IdHasher { - return new Argon2IdHasher($this->config->array('hashing.argon', [])); + return new Argon2IdHasher($this->config->array('hashing.argon')); } /** @@ -98,6 +98,6 @@ public function verifyConfiguration(string $hashedValue): bool */ public function getDefaultDriver(): string { - return $this->config->string('hashing.driver', 'bcrypt'); + return $this->config->string('hashing.driver'); } } diff --git a/tests/Hashing/HasherTest.php b/tests/Hashing/HasherTest.php index b3b0bbbc1..25c5d8040 100644 --- a/tests/Hashing/HasherTest.php +++ b/tests/Hashing/HasherTest.php @@ -235,21 +235,6 @@ protected function isUsingCorrectAlgorithm(string $hashedValue): bool $this->assertFalse($hasher->check('password', 'not-a-hash')); } - public function testManagerFallsBackToDefaultHashingConfig(): void - { - $container = m::mock(Container::class); - $container->shouldReceive('make') - ->with('config') - ->andReturn(new ConfigRepository([])); - - $manager = new HashManager($container); - - $this->assertSame('bcrypt', $manager->getDefaultDriver()); - $this->assertInstanceOf(BcryptHasher::class, $manager->createBcryptDriver()); - $this->assertInstanceOf(ArgonHasher::class, $manager->createArgonDriver()); - $this->assertInstanceOf(Argon2IdHasher::class, $manager->createArgon2idDriver()); - } - protected function getContainer(?array $hashing = null): Container { $hashing ??= [ diff --git a/tests/Hashing/HashingServiceProviderTest.php b/tests/Hashing/HashingServiceProviderTest.php index 56cfeb48e..150a4fafb 100644 --- a/tests/Hashing/HashingServiceProviderTest.php +++ b/tests/Hashing/HashingServiceProviderTest.php @@ -20,8 +20,17 @@ public function testReloadConfigurationRebuildsResolvedDriversFromCurrentConfigu $config = new Repository([ 'hashing' => [ 'driver' => 'bcrypt', - 'bcrypt' => [], - 'argon' => [], + 'bcrypt' => [ + 'rounds' => 12, + 'verify' => true, + 'limit' => null, + ], + 'argon' => [ + 'memory' => 65536, + 'threads' => 1, + 'time' => 4, + 'verify' => true, + ], ], ]); $application->instance('config', $config); From 529d1e382b1243d9a67ebf2960d94ee6a9ba3019 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:41:48 +0000 Subject: [PATCH 014/109] broadcasting: consume complete named connection records Require declared Redis connections and JSONP settings for named built-in broadcasters while retaining the established defaults on the public Pusher construction path. Keep Ably as an open SDK option bag rather than inventing framework-owned members. Cover shipped Pusher and Reverb logging flags, named record failures, Redis resolution, and partial public Pusher records, and clarify that the connection log flag controls SDK logging. --- src/broadcasting/src/BroadcastManager.php | 4 +-- src/docs/broadcasting.md | 2 ++ .../Broadcasting/BroadcastManagerTest.php | 34 +++++++++++++++---- 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/broadcasting/src/BroadcastManager.php b/src/broadcasting/src/BroadcastManager.php index a42353975..ec1ddf86f 100644 --- a/src/broadcasting/src/BroadcastManager.php +++ b/src/broadcasting/src/BroadcastManager.php @@ -368,7 +368,7 @@ protected function createPusherDriver(array $config): Broadcaster return new PusherBroadcaster( $this->app, $this->pusher($config), - (bool) ($config['jsonp'] ?? false), + $config['jsonp'], ); } @@ -426,7 +426,7 @@ protected function createRedisDriver(array $config): Broadcaster { /** @var RedisFactory $redis */ $redis = $this->app->make('redis'); - $connectionName = $config['connection'] ?? 'default'; + $connectionName = $config['connection']; $redisConfig = $this->app->make(RedisConfig::class)->connectionConfig($connectionName); return new RedisBroadcaster( diff --git a/src/docs/broadcasting.md b/src/docs/broadcasting.md index 7e7d84b96..fd2193830 100644 --- a/src/docs/broadcasting.md +++ b/src/docs/broadcasting.md @@ -154,6 +154,8 @@ PUSHER_APP_CLUSTER="mt1" The `config/broadcasting.php` file's `pusher` configuration also allows you to specify additional `options` that are supported by Channels, such as the cluster. +The connection's `log` option enables logging within the Pusher SDK. This is separate from Hypervel's `log` broadcast driver, which writes broadcast events to your application's log instead of sending them to Pusher. + Pusher JSONP responses are disabled by default. If a legacy client requires JSONP, you may explicitly enable it by setting the connection's `jsonp` option to `true`. Then, set the `BROADCAST_CONNECTION` environment variable to `pusher` in your application's `.env` file: diff --git a/tests/Integration/Broadcasting/BroadcastManagerTest.php b/tests/Integration/Broadcasting/BroadcastManagerTest.php index 95b4da5ad..64f4cf6de 100644 --- a/tests/Integration/Broadcasting/BroadcastManagerTest.php +++ b/tests/Integration/Broadcasting/BroadcastManagerTest.php @@ -307,15 +307,19 @@ public function testRedisDriverUsesCanonicalPrefixPrecedence( array $connectionConfig, string $expectedPrefix, ): void { - config()->set('database.redis', [ - 'client' => 'phpredis', - 'options' => $sharedOptions, - 'broadcasting' => array_merge([ + $redisConfig = config()->array('database.redis'); + $redisConfig['options'] = $sharedOptions; + $redisConfig['broadcasting'] = array_replace( + $redisConfig['default'], + [ 'host' => '127.0.0.1', 'port' => 6379, 'database' => 0, - ], $connectionConfig), - ]); + ], + $connectionConfig, + ); + + config()->set('database.redis', $redisConfig); config()->set('broadcasting.connections.redis-test', [ 'driver' => 'redis', 'connection' => 'broadcasting', @@ -538,6 +542,9 @@ public function testBuiltInSdkDriversResolveDirectlyWithoutDefaultPools(): void 'secret' => 'secret', 'app_id' => 'app', 'options' => ['host' => '127.0.0.1'], + 'client_options' => [], + 'log' => false, + 'jsonp' => false, ], 'pusher' => [ 'driver' => 'pusher', @@ -545,6 +552,9 @@ public function testBuiltInSdkDriversResolveDirectlyWithoutDefaultPools(): void 'secret' => 'secret', 'app_id' => 'app', 'options' => ['host' => '127.0.0.1'], + 'client_options' => [], + 'log' => false, + 'jsonp' => false, ], 'ably' => [ 'driver' => 'ably', @@ -573,6 +583,17 @@ public function testBuiltInSdkDriversResolveDirectlyWithoutDefaultPools(): void $this->assertSame($replacementAbly, $manager->getAbly()); } + public function testPublicPusherFactoryAcceptsAPartialRecord(): void + { + $manager = new BroadcastManager(new Container); + + $this->assertInstanceOf(Pusher::class, $manager->pusher([ + 'key' => 'key', + 'secret' => 'secret', + 'app_id' => 'app', + ])); + } + public function testPurgeInvalidatesCachedAndUncachedBroadcasterPoolsWhileForgetIsCacheOnly(): void { $app = $this->poolingApplication([ @@ -705,6 +726,7 @@ public function testThrowExceptionWhenDriverCreationFails(): void 'connections' => [ 'failing' => [ 'driver' => 'redis', + 'connection' => 'default', ], ], ], From 42c8fe541beba297701d1db6cb322be4c28b05f6 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:41:56 +0000 Subject: [PATCH 015/109] queue: make built-in connector records explicit Have built-in connectors consume complete selected connection records for commit behavior, timeouts, Redis migration batching, and SQS client options. Remove the hidden SQS default record, reject incomplete static credential pairs, preserve the AWS default chain for null credentials, and keep session tokens separate from the SDK's bearer-token option. Read failed-job configuration once, expand connector and integration coverage for shipped records and missing members, and document complete SQS pooling, credential, timeout, and fingerprint examples. --- src/bus/src/UniqueJobPayloadContext.php | 2 +- src/docs/queues.md | 44 +++++++------ .../src/Connectors/BackgroundConnector.php | 2 +- .../src/Connectors/BeanstalkdConnector.php | 13 ++-- .../src/Connectors/DatabaseConnector.php | 6 +- .../src/Connectors/DeferredConnector.php | 2 +- src/queue/src/Connectors/RedisConnector.php | 12 ++-- src/queue/src/Connectors/SqsConnector.php | 61 ++++++++++--------- src/queue/src/Connectors/SyncConnector.php | 2 +- src/queue/src/Jobs/Job.php | 5 +- tests/Integration/Queue/JobChainingTest.php | 10 ++- .../Integration/Queue/QueueConnectionTest.php | 14 +++++ tests/Queue/QueueConfigTest.php | 13 ++++ tests/Queue/QueueSqsConnectorTest.php | 45 +++++++++++++- 14 files changed, 161 insertions(+), 70 deletions(-) diff --git a/src/bus/src/UniqueJobPayloadContext.php b/src/bus/src/UniqueJobPayloadContext.php index 76b19682c..ee19009e0 100644 --- a/src/bus/src/UniqueJobPayloadContext.php +++ b/src/bus/src/UniqueJobPayloadContext.php @@ -70,7 +70,7 @@ protected static function getCacheStore(ShouldBeUnique $job): ?string { return method_exists($job, 'uniqueVia') ? $job->uniqueVia()->getName() - : config('cache.default'); + : config()->string('cache.default'); } /** diff --git a/src/docs/queues.md b/src/docs/queues.md index bed4a1745..7009b1c38 100644 --- a/src/docs/queues.md +++ b/src/docs/queues.md @@ -122,9 +122,25 @@ Configure a connection pool inside its queue connection definition: 'driver' => 'sqs', 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), - 'prefix' => env('SQS_PREFIX'), + 'token' => null, + 'credentials' => null, + 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), 'queue' => env('SQS_QUEUE', 'default'), + 'suffix' => env('SQS_SUFFIX'), 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'version' => 'latest', + 'http' => [ + 'timeout' => 60, + 'connect_timeout' => 60, + ], + 'after_commit' => false, + 'overflow' => [ + 'enabled' => env('SQS_OVERFLOW_ENABLED', false), + 'store' => env('SQS_OVERFLOW_STORE'), + 'always' => false, + 'delete_after_processing' => true, + 'flush_on_clear' => env('SQS_OVERFLOW_FLUSH_ON_CLEAR', false), + ], 'pool' => [ 'min_retained_objects' => 1, 'max_objects' => 10, @@ -136,6 +152,8 @@ Configure a connection pool inside its queue connection definition: ], ``` +When `credentials` is null, Hypervel uses a complete `key` and `secret` pair, or the AWS SDK's default credential chain when both are null. Configure both static values together. A non-null `credentials` value takes precedence and may contain an AWS credential value or a supported `ecs` or `instance` provider. If you supply callable or object credentials, set `pool.fingerprint` because these values cannot form an automatic pool identity. A null `token` means that the static credentials do not use a temporary AWS session token. + `min_retained_objects` is an idle-trimming floor and does not eagerly connect. `max_objects` should be at least the maximum number of jobs a worker may process concurrently: a popped SQS or Beanstalkd job keeps its connection leased until `delete()`, `release()`, or `bury()` finishes. Backend failures discard the leased connection so a potentially desynchronized client is never returned to the pool. Automatic identities are sufficient for scalar and array connector configuration. Use `pool.name` for an explicit readable identity and `pool.fingerprint` when custom connector input contains an object, closure, or resource. Reusing an explicit name with a different driver, fingerprint, or normalized options fails immediately. @@ -203,25 +221,15 @@ Adjusting this value based on your queue load can be more efficient than continu #### SQS Overflow Storage -Amazon SQS limits the maximum size of a queued message payload. If you need to dispatch jobs with payloads that may exceed this limit, you may configure Hypervel to store oversized SQS payloads in a cache store and send a pointer through SQS instead. To enable this feature, add an `overflow` array to your SQS queue connection configuration: +Amazon SQS limits the maximum size of a queued message payload. If you need to dispatch jobs with payloads that may exceed this limit, you may configure Hypervel to store oversized SQS payloads in a cache store and send a pointer through SQS instead. To enable this feature, replace the `overflow` member inside your existing SQS connection with the following array: ```php -'sqs' => [ - 'driver' => 'sqs', - 'key' => env('AWS_ACCESS_KEY_ID'), - 'secret' => env('AWS_SECRET_ACCESS_KEY'), - 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), - 'queue' => env('SQS_QUEUE', 'default'), - 'suffix' => env('SQS_SUFFIX'), - 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), - 'after_commit' => false, - 'overflow' => [ - 'enabled' => env('SQS_OVERFLOW_ENABLED', false), - 'store' => env('SQS_OVERFLOW_STORE'), - 'always' => false, - 'delete_after_processing' => true, - 'flush_on_clear' => env('SQS_OVERFLOW_FLUSH_ON_CLEAR', false), - ], +'overflow' => [ + 'enabled' => env('SQS_OVERFLOW_ENABLED', false), + 'store' => env('SQS_OVERFLOW_STORE'), + 'always' => false, + 'delete_after_processing' => true, + 'flush_on_clear' => env('SQS_OVERFLOW_FLUSH_ON_CLEAR', false), ], ``` diff --git a/src/queue/src/Connectors/BackgroundConnector.php b/src/queue/src/Connectors/BackgroundConnector.php index 2c306c1d6..e6cf97586 100644 --- a/src/queue/src/Connectors/BackgroundConnector.php +++ b/src/queue/src/Connectors/BackgroundConnector.php @@ -23,7 +23,7 @@ public function __construct( */ public function connect(array $config): Queue { - return (new BackgroundQueue($config['after_commit'] ?? false)) + return (new BackgroundQueue($config['after_commit'])) ->setExceptionCallback($this->exceptionCallback); } } diff --git a/src/queue/src/Connectors/BeanstalkdConnector.php b/src/queue/src/Connectors/BeanstalkdConnector.php index bb07dc92b..b51d3c216 100644 --- a/src/queue/src/Connectors/BeanstalkdConnector.php +++ b/src/queue/src/Connectors/BeanstalkdConnector.php @@ -6,7 +6,6 @@ use Hypervel\Contracts\Queue\Queue; use Hypervel\Queue\BeanstalkdQueue; -use Pheanstalk\Contract\SocketFactoryInterface; use Pheanstalk\Pheanstalk; use Pheanstalk\Values\Timeout; @@ -20,9 +19,9 @@ public function connect(array $config): Queue return new BeanstalkdQueue( $this->pheanstalk($config), $config['queue'], - $config['retry_after'] ?? Pheanstalk::DEFAULT_TTR, - $config['block_for'] ?? 0, - $config['after_commit'] ?? false + $config['retry_after'], + $config['block_for'], + $config['after_commit'] ); } @@ -31,10 +30,12 @@ public function connect(array $config): Queue */ protected function pheanstalk(array $config): Pheanstalk { + $timeout = $config['timeout']; + return Pheanstalk::create( $config['host'], - $config['port'] ?? SocketFactoryInterface::DEFAULT_PORT, - isset($config['timeout']) ? new Timeout($config['timeout']) : null, + $config['port'], + $timeout === null ? null : new Timeout($timeout), ); } } diff --git a/src/queue/src/Connectors/DatabaseConnector.php b/src/queue/src/Connectors/DatabaseConnector.php index 02e70f4d7..6d492f1a3 100644 --- a/src/queue/src/Connectors/DatabaseConnector.php +++ b/src/queue/src/Connectors/DatabaseConnector.php @@ -25,11 +25,11 @@ public function connect(array $config): Queue { return new DatabaseQueue( $this->connections, - $config['connection'] ?? null, + $config['connection'], $config['table'], $config['queue'], - $config['retry_after'] ?? 60, - $config['after_commit'] ?? false + $config['retry_after'], + $config['after_commit'] ); } } diff --git a/src/queue/src/Connectors/DeferredConnector.php b/src/queue/src/Connectors/DeferredConnector.php index a59e7d066..4379039c6 100644 --- a/src/queue/src/Connectors/DeferredConnector.php +++ b/src/queue/src/Connectors/DeferredConnector.php @@ -23,7 +23,7 @@ public function __construct( */ public function connect(array $config): Queue { - return (new DeferredQueue($config['after_commit'] ?? false)) + return (new DeferredQueue($config['after_commit'])) ->setExceptionCallback($this->exceptionCallback); } } diff --git a/src/queue/src/Connectors/RedisConnector.php b/src/queue/src/Connectors/RedisConnector.php index 323ffaf10..5ea7aad21 100644 --- a/src/queue/src/Connectors/RedisConnector.php +++ b/src/queue/src/Connectors/RedisConnector.php @@ -24,14 +24,16 @@ public function __construct( */ public function connect(array $config): Queue { + $connection = $config['connection']; + return new RedisQueue( $this->redis, $config['queue'], - $config['connection'] ?? $this->connection, - $config['retry_after'] ?? 60, - $config['block_for'] ?? null, - $config['after_commit'] ?? false, - $config['migration_batch_size'] ?? -1 + $connection ?? $this->connection, + $config['retry_after'], + $config['block_for'], + $config['after_commit'], + $config['migration_batch_size'] ); } } diff --git a/src/queue/src/Connectors/SqsConnector.php b/src/queue/src/Connectors/SqsConnector.php index adde3c3b4..82e999c64 100644 --- a/src/queue/src/Connectors/SqsConnector.php +++ b/src/queue/src/Connectors/SqsConnector.php @@ -18,27 +18,44 @@ class SqsConnector implements ConnectorInterface */ public function connect(array $config): Queue { - $config = $this->getDefaultConfiguration($config); + $key = $config['key']; + $secret = $config['secret']; + $token = $config['token']; + $credentials = $config['credentials']; + $suffix = $config['suffix']; - if ($credentials = $this->resolveCredentialProvider($config)) { - $config['credentials'] = $credentials; - } elseif (! empty($config['key']) && ! empty($config['secret'])) { - $config['credentials'] = Arr::only($config, ['key', 'secret']); + if (($resolvedCredentials = $this->resolveCredentialProvider($config)) !== null) { + $config['credentials'] = $resolvedCredentials; + } elseif ($credentials === null && empty($key) !== empty($secret)) { + throw new InvalidArgumentException('The SQS access key and secret must be configured together.'); + } elseif ($credentials === null && ! empty($key) && ! empty($secret)) { + $config['credentials'] = ['key' => $key, 'secret' => $secret]; - if (! empty($config['token'])) { - $config['credentials']['token'] = $config['token']; + if (! empty($token)) { + $config['credentials']['token'] = $token; } } + // The queue token is an AWS session credential, while the SDK's + // top-level token option is an unrelated bearer token. + $clientConfig = [ + 'region' => $config['region'], + 'version' => $config['version'], + 'http' => [ + 'timeout' => $config['http']['timeout'], + 'connect_timeout' => $config['http']['connect_timeout'], + ...Arr::except($config['http'], ['timeout', 'connect_timeout']), + ], + ...Arr::except($config, ['token', 'overflow', 'region', 'version', 'http']), + ]; + return new SqsQueue( - new SqsClient( - Arr::except($config, ['token', 'overflow']) - ), + new SqsClient($clientConfig), $config['queue'], - $config['prefix'] ?? '', - $config['suffix'] ?? '', - $config['after_commit'] ?? false, - $config['overflow'] ?? [], + $config['prefix'], + $suffix ?? '', + $config['after_commit'], + $config['overflow'], ); } @@ -49,7 +66,7 @@ public function connect(array $config): Queue */ protected function resolveCredentialProvider(array $config): mixed { - $credentials = $config['credentials'] ?? null; + $credentials = $config['credentials']; $provider = is_array($credentials) ? ($credentials['provider'] ?? null) : $credentials; @@ -69,18 +86,4 @@ protected function resolveCredentialProvider(array $config): mixed return CredentialProvider::memoize($resolved); } - - /** - * Get the default configuration for SQS. - */ - protected function getDefaultConfiguration(array $config): array - { - return array_merge([ - 'version' => 'latest', - 'http' => [ - 'timeout' => 60, - 'connect_timeout' => 60, - ], - ], $config); - } } diff --git a/src/queue/src/Connectors/SyncConnector.php b/src/queue/src/Connectors/SyncConnector.php index 16ad26467..fe7a4c792 100644 --- a/src/queue/src/Connectors/SyncConnector.php +++ b/src/queue/src/Connectors/SyncConnector.php @@ -14,6 +14,6 @@ class SyncConnector implements ConnectorInterface */ public function connect(array $config): Queue { - return new SyncQueue($config['after_commit'] ?? false); + return new SyncQueue($config['after_commit']); } } diff --git a/src/queue/src/Jobs/Job.php b/src/queue/src/Jobs/Job.php index 38a15251e..05b60deeb 100644 --- a/src/queue/src/Jobs/Job.php +++ b/src/queue/src/Jobs/Job.php @@ -304,9 +304,10 @@ protected function shouldRollBackDatabaseTransaction(?Throwable $e): bool } $config = $this->container->make('config'); + $failed = $config->array('queue.failed'); - return $config->get('queue.failed.database') - && in_array($config->get('queue.failed.driver'), ['database', 'database-uuids'], true) + return in_array($failed['driver'], ['database', 'database-uuids'], true) + && $failed['database'] !== '' && $this->container->bound('db'); } diff --git a/tests/Integration/Queue/JobChainingTest.php b/tests/Integration/Queue/JobChainingTest.php index a42113b48..ba6b03440 100644 --- a/tests/Integration/Queue/JobChainingTest.php +++ b/tests/Integration/Queue/JobChainingTest.php @@ -33,8 +33,14 @@ protected function defineEnvironment(ApplicationContract $app): void parent::defineEnvironment($app); $app->make('config')->set([ - 'queue.connections.sync1' => ['driver' => 'sync'], - 'queue.connections.sync2' => ['driver' => 'sync'], + 'queue.connections.sync1' => [ + 'driver' => 'sync', + 'after_commit' => false, + ], + 'queue.connections.sync2' => [ + 'driver' => 'sync', + 'after_commit' => false, + ], ]); } diff --git a/tests/Integration/Queue/QueueConnectionTest.php b/tests/Integration/Queue/QueueConnectionTest.php index ab3ee50a6..5b0be4905 100644 --- a/tests/Integration/Queue/QueueConnectionTest.php +++ b/tests/Integration/Queue/QueueConnectionTest.php @@ -4,12 +4,15 @@ namespace Hypervel\Tests\Integration\Queue\QueueConnectionTest; +use ErrorException; use Hypervel\Bus\Queueable; use Hypervel\Contracts\Queue\ShouldBeUnique; use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Database\DatabaseTransactionRecord; use Hypervel\Database\DatabaseTransactionsManager; use Hypervel\Foundation\Bus\Dispatchable; +use Hypervel\Queue\Connectors\SqsConnector; +use Hypervel\Support\Arr; use Hypervel\Support\Collection; use Hypervel\Support\Facades\Bus; use Hypervel\Testbench\Attributes\WithConfig; @@ -138,6 +141,17 @@ public function testUniqueJobWontGetDispatchedInsideATransactionWhenExplicitlyIn // This job was dispatched } } + + public function testSqsConnectorRequiresCompleteHttpConfiguration(): void + { + $config = config()->array('queue.connections.sqs'); + unset($config['http']['connect_timeout']); + + $this->expectException(ErrorException::class); + $this->expectExceptionMessage('Undefined array key "connect_timeout"'); + + (new SqsConnector)->connect(Arr::except($config, ['pool'])); + } } class QueueConnectionTestJob implements ShouldQueue diff --git a/tests/Queue/QueueConfigTest.php b/tests/Queue/QueueConfigTest.php index 8f874c976..617bb47a5 100644 --- a/tests/Queue/QueueConfigTest.php +++ b/tests/Queue/QueueConfigTest.php @@ -38,6 +38,19 @@ public function testSqsOverflowStorageHasSecureDefaults(): void }); } + public function testSqsSdkConfigurationIsExplicit(): void + { + $config = $this->loadConfig()['connections']['sqs']; + + $this->assertNull($config['token']); + $this->assertNull($config['credentials']); + $this->assertSame('latest', $config['version']); + $this->assertSame([ + 'timeout' => 60, + 'connect_timeout' => 60, + ], $config['http']); + } + public function testFileFailedJobStorageDefaultsAreOwnedByTheProvider(): void { $config = $this->loadConfig(); diff --git a/tests/Queue/QueueSqsConnectorTest.php b/tests/Queue/QueueSqsConnectorTest.php index c1453b84a..b35ae3532 100644 --- a/tests/Queue/QueueSqsConnectorTest.php +++ b/tests/Queue/QueueSqsConnectorTest.php @@ -18,7 +18,7 @@ class QueueSqsConnectorTest extends TestCase { - public function testConnectSucceedsWithoutAfterCommitConfig(): void + public function testConnectSucceedsWithCompleteConfigurationAndNullCredentials(): void { $connector = new SqsConnector; @@ -27,6 +27,26 @@ public function testConnectSucceedsWithoutAfterCommitConfig(): void $this->assertInstanceOf(SqsQueue::class, $queue); } + #[DataProvider('incompleteStaticCredentials')] + public function testConnectRejectsIncompleteStaticCredentials(?string $key, ?string $secret): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The SQS access key and secret must be configured together.'); + + (new SqsConnector)->connect($this->config([ + 'key' => $key, + 'secret' => $secret, + ])); + } + + public static function incompleteStaticCredentials(): array + { + return [ + 'key only' => ['key', null], + 'secret only' => [null, 'secret'], + ]; + } + public function testConnectBuildsStaticCredentialsWithToken(): void { $queue = (new SqsConnector)->connect($this->config([ @@ -101,6 +121,9 @@ public function testOverflowOptionsArePassedToTheQueueButNotTheAwsClient(): void $overflow = [ 'enabled' => true, 'store' => 'sqs-overflow', + 'always' => false, + 'delete_after_processing' => true, + 'flush_on_clear' => false, ]; $queue = (new SqsConnector)->connect($this->config([ @@ -114,8 +137,28 @@ public function testOverflowOptionsArePassedToTheQueueButNotTheAwsClient(): void protected function config(array $overrides = []): array { return [ + 'driver' => 'sqs', + 'key' => null, + 'secret' => null, + 'token' => null, + 'credentials' => null, + 'prefix' => 'https://sqs.us-east-1.amazonaws.com/account', 'queue' => 'default', + 'suffix' => null, 'region' => 'us-east-1', + 'version' => 'latest', + 'http' => [ + 'timeout' => 60, + 'connect_timeout' => 60, + ], + 'after_commit' => false, + 'overflow' => [ + 'enabled' => false, + 'store' => null, + 'always' => false, + 'delete_after_processing' => true, + 'flush_on_clear' => false, + ], ...$overrides, ]; } From 73467fef710a581555cb8538241264d0c4e0575c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:42:03 +0000 Subject: [PATCH 016/109] horizon: make runtime configuration explicit Read required trim, metrics, Redis, dashboard, notification, and provisioning settings by type while preserving dynamic queue wait thresholds and the documented environment fallback chain. Normalize Redis's empty-string representation back to null so persisted master records continue to inherit app.env correctly. Add coverage for shipped config, missing nested members, command precedence, null environment persistence through the real repository, Redis connector records, and watcher-path inheritance, and document the advanced HORIZON_ENV override. --- src/docs/horizon.md | 2 + src/horizon/config/horizon.php | 17 +++++++- src/horizon/resources/views/layout.blade.php | 4 +- src/horizon/src/Connectors/RedisConnector.php | 12 +++--- src/horizon/src/Console/ClearCommand.php | 2 +- src/horizon/src/Console/HorizonCommand.php | 2 +- src/horizon/src/Console/SnapshotCommand.php | 2 +- .../Controllers/DashboardStatsController.php | 4 +- .../MasterSupervisorController.php | 2 +- src/horizon/src/JobPayload.php | 4 +- .../src/Listeners/StoreTagsForFailedJob.php | 2 +- src/horizon/src/Listeners/TrimFailedJobs.php | 2 +- .../src/Listeners/TrimMonitoredJobs.php | 2 +- .../src/Notifications/LongWaitDetected.php | 4 +- src/horizon/src/ProvisioningPlan.php | 6 ++- .../src/Repositories/RedisJobRepository.php | 12 +++--- .../RedisMasterSupervisorRepository.php | 6 ++- .../Repositories/RedisMetricsRepository.php | 4 +- tests/Horizon/Console/SnapshotCommandTest.php | 9 ++-- tests/Horizon/HorizonConfigTest.php | 1 + tests/Horizon/Unit/RedisConnectorTest.php | 7 +++- .../MasterSupervisorControllerTest.php | 42 +++++++++++++++++++ .../Horizon/Feature/HorizonCommandTest.php | 37 +++++++++++++--- .../Listeners/StoreTagsForFailedTest.php | 19 +++++---- .../Horizon/IntegrationTestCase.php | 1 + tests/Integration/Horizon/worker.php | 1 + 26 files changed, 158 insertions(+), 48 deletions(-) diff --git a/src/docs/horizon.md b/src/docs/horizon.md index 141367563..43645c413 100644 --- a/src/docs/horizon.md +++ b/src/docs/horizon.md @@ -134,6 +134,8 @@ You may also define a wildcard environment (`*`) which will be used when no othe When you start Horizon, it will use the worker process configuration options for the environment that your application is running on. Typically, the environment is determined by the value of the `APP_ENV` [environment variable](/docs/{{version}}/configuration#determining-the-current-environment). For example, the default `local` Horizon environment is configured to start three worker processes and automatically balance the number of worker processes assigned to each queue. The default `production` environment is configured to start a maximum of 10 worker processes and automatically balance the number of worker processes assigned to each queue. +For advanced deployments, the top-level `env` option or `HORIZON_ENV` environment variable may select a Horizon provisioning environment independently of `app.env`. Set it to null to inherit the application environment. The `horizon` command's `--environment` option takes precedence over both values. + > [!WARNING] > You should ensure that the `environments` portion of your `horizon` configuration file contains an entry for each [environment](/docs/{{version}}/configuration#environment-configuration) on which you plan to run Horizon. diff --git a/src/horizon/config/horizon.php b/src/horizon/config/horizon.php index afe7d4a4b..0a52a7a68 100644 --- a/src/horizon/config/horizon.php +++ b/src/horizon/config/horizon.php @@ -95,7 +95,8 @@ | | This option allows you to configure when the LongWaitDetected event | will be fired. Every connection / queue combination may have its - | own, unique threshold (in seconds) before this event is fired. + | own threshold in seconds. Unlisted combinations use 60 seconds, + | while a threshold of zero disables the event for that queue. | */ @@ -190,6 +191,20 @@ 'memory_limit' => 64, + /* + |-------------------------------------------------------------------------- + | Horizon Environment + |-------------------------------------------------------------------------- + | + | This advanced override selects which provisioning environment Horizon + | uses independently of the application environment. Set it to null to + | inherit app.env. The horizon command's --environment option takes + | precedence over both settings. + | + */ + + 'env' => env('HORIZON_ENV'), + /* |-------------------------------------------------------------------------- | Queue Worker Configuration diff --git a/src/horizon/resources/views/layout.blade.php b/src/horizon/resources/views/layout.blade.php index a876adb07..bc831dbfd 100644 --- a/src/horizon/resources/views/layout.blade.php +++ b/src/horizon/resources/views/layout.blade.php @@ -7,7 +7,7 @@ - Horizon{{ config('horizon.name') ? ' - ' . config('horizon.name') : '' }} + Horizon{{ config()->string('horizon.name') ? ' - ' . config()->string('horizon.name') : '' }} @@ -32,7 +32,7 @@

- Hypervel Horizon{{ config('horizon.name') ? ' - ' . config('horizon.name') : '' }} + Hypervel Horizon{{ config()->string('horizon.name') ? ' - ' . config()->string('horizon.name') : '' }}

diff --git a/src/horizon/src/Connectors/RedisConnector.php b/src/horizon/src/Connectors/RedisConnector.php index 3b90d4d09..506051293 100644 --- a/src/horizon/src/Connectors/RedisConnector.php +++ b/src/horizon/src/Connectors/RedisConnector.php @@ -6,7 +6,6 @@ use Hypervel\Horizon\RedisQueue; use Hypervel\Queue\Connectors\RedisConnector as BaseConnector; -use Hypervel\Support\Arr; class RedisConnector extends BaseConnector { @@ -15,13 +14,16 @@ class RedisConnector extends BaseConnector */ public function connect(array $config): RedisQueue { + $connection = $config['connection']; + return new RedisQueue( $this->redis, $config['queue'], - Arr::get($config, 'connection', $this->connection), - Arr::get($config, 'retry_after', 60), - Arr::get($config, 'block_for', null), - Arr::get($config, 'after_commit', false) + $connection ?? $this->connection, + $config['retry_after'], + $config['block_for'], + $config['after_commit'], + $config['migration_batch_size'], ); } } diff --git a/src/horizon/src/Console/ClearCommand.php b/src/horizon/src/Console/ClearCommand.php index 92d846517..7389e10d9 100644 --- a/src/horizon/src/Console/ClearCommand.php +++ b/src/horizon/src/Console/ClearCommand.php @@ -40,7 +40,7 @@ public function handle(JobRepository $jobRepository, QueueManager $manager): ?in $connection = $this->argument('connection'); if ($connection === null || $connection === '') { - $connection = array_first(config('horizon.defaults'))['connection'] ?? 'redis'; + $connection = array_first(config()->array('horizon.defaults'))['connection']; } $queue = $this->getQueue($connection); diff --git a/src/horizon/src/Console/HorizonCommand.php b/src/horizon/src/Console/HorizonCommand.php index 4e029a573..21c3f247f 100644 --- a/src/horizon/src/Console/HorizonCommand.php +++ b/src/horizon/src/Console/HorizonCommand.php @@ -34,7 +34,7 @@ public function handle(MasterSupervisorRepository $masters): int return self::SUCCESS; } - $environment = $this->option('environment') ?? config('horizon.env') ?? config('app.env'); + $environment = $this->option('environment') ?? config('horizon.env') ?? config()->string('app.env'); $master = (new MasterSupervisor($environment))->handleOutputUsing(function ($type, $line) { $this->output->write($line); diff --git a/src/horizon/src/Console/SnapshotCommand.php b/src/horizon/src/Console/SnapshotCommand.php index 76f97050e..89d2e28f0 100644 --- a/src/horizon/src/Console/SnapshotCommand.php +++ b/src/horizon/src/Console/SnapshotCommand.php @@ -27,7 +27,7 @@ class SnapshotCommand extends Command */ public function handle(Lock $lock, MetricsRepository $metrics): void { - $seconds = config()->integer('horizon.metrics.snapshot_lock', 300) - 30; + $seconds = config()->integer('horizon.metrics.snapshot_lock') - 30; if ($lock->get('metrics:snapshot', $seconds)) { $metrics->snapshot(); diff --git a/src/horizon/src/Http/Controllers/DashboardStatsController.php b/src/horizon/src/Http/Controllers/DashboardStatsController.php index 5ea0e576a..6520d7a1a 100644 --- a/src/horizon/src/Http/Controllers/DashboardStatsController.php +++ b/src/horizon/src/Http/Controllers/DashboardStatsController.php @@ -22,8 +22,8 @@ public function index(): array 'jobsPerMinute' => app(MetricsRepository::class)->jobsProcessedPerMinute(), 'pausedMasters' => $this->totalPausedMasters(), 'periods' => [ - 'failedJobs' => config('horizon.trim.recent_failed', config('horizon.trim.failed')), - 'recentJobs' => config('horizon.trim.recent'), + 'failedJobs' => config()->integer('horizon.trim.recent_failed'), + 'recentJobs' => config()->integer('horizon.trim.recent'), ], 'processes' => $this->totalProcessCount(), 'queueWithMaxRuntime' => app(MetricsRepository::class)->queueWithMaximumRuntime(), diff --git a/src/horizon/src/Http/Controllers/MasterSupervisorController.php b/src/horizon/src/Http/Controllers/MasterSupervisorController.php index fead3dc20..90e3e6796 100644 --- a/src/horizon/src/Http/Controllers/MasterSupervisorController.php +++ b/src/horizon/src/Http/Controllers/MasterSupervisorController.php @@ -25,7 +25,7 @@ public function index( return $masters->each(function ($master, $name) use ($supervisors) { $master->supervisors = ($supervisors->get($name) ?? collect()) ->merge( - collect(ProvisioningPlan::get($name)->plan[$master->environment ?? config('horizon.env') ?? config('app.env')] ?? []) + collect(ProvisioningPlan::get($name)->plan[$master->environment ?? config('horizon.env') ?? config()->string('app.env')] ?? []) ->map(function ($value, $key) use ($name) { return (object) [ 'name' => $name . ':' . $key, diff --git a/src/horizon/src/JobPayload.php b/src/horizon/src/JobPayload.php index 0831c0a86..42fe0642f 100644 --- a/src/horizon/src/JobPayload.php +++ b/src/horizon/src/JobPayload.php @@ -143,9 +143,9 @@ protected function shouldBeSilenced(mixed $job, array $tags): bool $jobClass = is_string($underlyingJob) ? $underlyingJob : get_class($underlyingJob); - return in_array($jobClass, config('horizon.silenced', [])) + return in_array($jobClass, config()->array('horizon.silenced'), true) || is_a($jobClass, Silenced::class, true) - || count(array_intersect($tags, config('horizon.silenced_tags', []))) > 0; + || count(array_intersect($tags, config()->array('horizon.silenced_tags'))) > 0; } /** diff --git a/src/horizon/src/Listeners/StoreTagsForFailedJob.php b/src/horizon/src/Listeners/StoreTagsForFailedJob.php index d5208899c..592484dce 100644 --- a/src/horizon/src/Listeners/StoreTagsForFailedJob.php +++ b/src/horizon/src/Listeners/StoreTagsForFailedJob.php @@ -29,7 +29,7 @@ public function handle(JobFailed $event): void })->all(); $this->tags->addTemporary( - config('horizon.trim.failed', 10080), + config()->integer('horizon.trim.failed'), $event->payload->id(), $tags ); diff --git a/src/horizon/src/Listeners/TrimFailedJobs.php b/src/horizon/src/Listeners/TrimFailedJobs.php index 4cccf8787..4b289395a 100644 --- a/src/horizon/src/Listeners/TrimFailedJobs.php +++ b/src/horizon/src/Listeners/TrimFailedJobs.php @@ -27,7 +27,7 @@ public function handle(MasterSupervisorLooped $event): void { if (! isset($this->lastTrimmed)) { $this->frequency = max(1, intdiv( - config('horizon.trim.failed', 10080), + config()->integer('horizon.trim.failed'), 12 )); diff --git a/src/horizon/src/Listeners/TrimMonitoredJobs.php b/src/horizon/src/Listeners/TrimMonitoredJobs.php index 0f6de5578..1e3e77eee 100644 --- a/src/horizon/src/Listeners/TrimMonitoredJobs.php +++ b/src/horizon/src/Listeners/TrimMonitoredJobs.php @@ -27,7 +27,7 @@ public function handle(MasterSupervisorLooped $event): void { if (! isset($this->lastTrimmed)) { $this->frequency = max(1, intdiv( - config('horizon.trim.monitored', 10080), + config()->integer('horizon.trim.monitored'), 12 )); diff --git a/src/horizon/src/Notifications/LongWaitDetected.php b/src/horizon/src/Notifications/LongWaitDetected.php index 8e3d5a996..9255079eb 100644 --- a/src/horizon/src/Notifications/LongWaitDetected.php +++ b/src/horizon/src/Notifications/LongWaitDetected.php @@ -50,7 +50,7 @@ public function toMail(mixed $notifiable): MailMessage { return (new MailMessage) ->error() - ->subject(config('horizon.name') . ': Long Queue Wait Detected') + ->subject(config()->string('horizon.name') . ': Long Queue Wait Detected') ->greeting('Oh no! Something needs your attention.') ->line(sprintf( 'The "%s" queue on the "%s" connection has a wait time of %s seconds.', @@ -71,7 +71,7 @@ public function toSlack(mixed $notifiable): ChannelIdSlackMessage|SlackMessage $content = sprintf( '[%s] The "%s" queue on the "%s" connection has a wait time of %s seconds.', - config('horizon.name'), + config()->string('horizon.name'), $this->longWaitQueue, $this->longWaitConnection, $this->seconds diff --git a/src/horizon/src/ProvisioningPlan.php b/src/horizon/src/ProvisioningPlan.php index b3c2da4f1..78391d9b4 100644 --- a/src/horizon/src/ProvisioningPlan.php +++ b/src/horizon/src/ProvisioningPlan.php @@ -42,7 +42,11 @@ public function __construct( */ public static function get(string $master): static { - return new static($master, config('horizon.environments'), config('horizon.defaults', [])); + return new static( + $master, + config()->array('horizon.environments'), + config()->array('horizon.defaults'), + ); } /** diff --git a/src/horizon/src/Repositories/RedisJobRepository.php b/src/horizon/src/Repositories/RedisJobRepository.php index ca0a71c2e..57d88dcea 100644 --- a/src/horizon/src/Repositories/RedisJobRepository.php +++ b/src/horizon/src/Repositories/RedisJobRepository.php @@ -64,12 +64,12 @@ class RedisJobRepository implements JobRepository public function __construct( public Redis $redis ) { - $this->recentJobExpires = (int) config('horizon.trim.recent', 60); - $this->pendingJobExpires = (int) config('horizon.trim.pending', 60); - $this->completedJobExpires = (int) config('horizon.trim.completed', 60); - $this->failedJobExpires = (int) config('horizon.trim.failed', 10080); - $this->recentFailedJobExpires = (int) config('horizon.trim.recent_failed', $this->failedJobExpires); - $this->monitoredJobExpires = (int) config('horizon.trim.monitored', 10080); + $this->recentJobExpires = config()->integer('horizon.trim.recent'); + $this->pendingJobExpires = config()->integer('horizon.trim.pending'); + $this->completedJobExpires = config()->integer('horizon.trim.completed'); + $this->failedJobExpires = config()->integer('horizon.trim.failed'); + $this->recentFailedJobExpires = config()->integer('horizon.trim.recent_failed'); + $this->monitoredJobExpires = config()->integer('horizon.trim.monitored'); } /** diff --git a/src/horizon/src/Repositories/RedisMasterSupervisorRepository.php b/src/horizon/src/Repositories/RedisMasterSupervisorRepository.php index 23afacdbc..753e65f07 100644 --- a/src/horizon/src/Repositories/RedisMasterSupervisorRepository.php +++ b/src/horizon/src/Repositories/RedisMasterSupervisorRepository.php @@ -66,7 +66,11 @@ public function get(array $names): array return collect($records)->map(function ($record) { return $record['name'] - ? (object) array_merge($record, ['supervisors' => json_decode($record['supervisors'], true)]) + ? (object) array_merge($record, [ + 'supervisors' => json_decode($record['supervisors'], true), + // Redis stores null hash values as empty strings. + 'environment' => $record['environment'] === '' ? null : $record['environment'], + ]) : null; })->filter()->all(); } diff --git a/src/horizon/src/Repositories/RedisMetricsRepository.php b/src/horizon/src/Repositories/RedisMetricsRepository.php index f0c9cfc83..096052290 100644 --- a/src/horizon/src/Repositories/RedisMetricsRepository.php +++ b/src/horizon/src/Repositories/RedisMetricsRepository.php @@ -228,7 +228,7 @@ protected function storeSnapshotForJob(string $job): void $this->connection()->zRemRangeByRank( 'snapshot:' . $key, 0, - -abs(1 + config('horizon.metrics.trim_snapshots.job', 24)) + -abs(1 + config()->integer('horizon.metrics.trim_snapshots.job')) ); } @@ -253,7 +253,7 @@ protected function storeSnapshotForQueue(string $queue): void $this->connection()->zRemRangeByRank( 'snapshot:' . $key, 0, - -abs(1 + config('horizon.metrics.trim_snapshots.queue', 24)) + -abs(1 + config()->integer('horizon.metrics.trim_snapshots.queue')) ); } diff --git a/tests/Horizon/Console/SnapshotCommandTest.php b/tests/Horizon/Console/SnapshotCommandTest.php index 254038b04..bdf79bcba 100644 --- a/tests/Horizon/Console/SnapshotCommandTest.php +++ b/tests/Horizon/Console/SnapshotCommandTest.php @@ -10,6 +10,7 @@ use Hypervel\Horizon\HorizonServiceProvider; use Hypervel\Horizon\Lock; use Hypervel\Testbench\TestCase; +use InvalidArgumentException; use Mockery as m; class SnapshotCommandTest extends TestCase @@ -32,17 +33,17 @@ public function testDefaultSnapshotLockLeavesAThirtySecondSafetyMargin(): void ); } - public function testSnapshotLockDefaultSurvivesReplaceWholeMetricsConfiguration(): void + public function testSnapshotLockIsRequiredWhenMetricsConfigurationIsReplaced(): void { config(['horizon.metrics' => [ 'trim_snapshots' => ['job' => 12, 'queue' => 12], ]]); - $lock = m::mock(Lock::class); - $lock->shouldReceive('get')->once()->with('metrics:snapshot', 270)->andReturnFalse(); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('horizon.metrics.snapshot_lock'); $this->app->make(SnapshotCommand::class)->handle( - $lock, + m::mock(Lock::class), m::mock(MetricsRepository::class), ); } diff --git a/tests/Horizon/HorizonConfigTest.php b/tests/Horizon/HorizonConfigTest.php index 4d8f6c2cf..1bed43c77 100644 --- a/tests/Horizon/HorizonConfigTest.php +++ b/tests/Horizon/HorizonConfigTest.php @@ -26,6 +26,7 @@ public function testCanonicalDefaultsAreDeclared(): void $this->assertSame(['web'], $config['middleware']); $this->assertFalse($config['fast_termination']); $this->assertSame(64, $config['memory_limit']); + $this->assertNull($config['env']); } public function testApplicationMetricsConfigurationReplacesPackageDefaults(): void diff --git a/tests/Horizon/Unit/RedisConnectorTest.php b/tests/Horizon/Unit/RedisConnectorTest.php index fbc781aa0..2544da9f9 100644 --- a/tests/Horizon/Unit/RedisConnectorTest.php +++ b/tests/Horizon/Unit/RedisConnectorTest.php @@ -12,13 +12,18 @@ class RedisConnectorTest extends UnitTestCase { - public function testConnectSucceedsWithoutAfterCommitConfig() + public function testConnectSucceedsWithCompleteConfiguration(): void { $redis = m::mock(Redis::class); $connector = new RedisConnector($redis); $queue = $connector->connect([ 'queue' => 'default', + 'connection' => 'queue', + 'retry_after' => 90, + 'block_for' => null, + 'after_commit' => false, + 'migration_batch_size' => -1, ]); $this->assertInstanceOf(RedisQueue::class, $queue); diff --git a/tests/Integration/Horizon/Controller/MasterSupervisorControllerTest.php b/tests/Integration/Horizon/Controller/MasterSupervisorControllerTest.php index 1362ec8ec..c60e76aa3 100644 --- a/tests/Integration/Horizon/Controller/MasterSupervisorControllerTest.php +++ b/tests/Integration/Horizon/Controller/MasterSupervisorControllerTest.php @@ -10,6 +10,7 @@ use Hypervel\Horizon\Supervisor; use Hypervel\Horizon\SupervisorOptions; use Hypervel\Tests\Integration\Horizon\ControllerTestCase; +use PHPUnit\Framework\Attributes\DataProvider; class MasterSupervisorControllerTest extends ControllerTestCase { @@ -98,4 +99,45 @@ public function testMasterSupervisorWithCustomNameListingWithSupervisors() ], ]); } + + #[DataProvider('environmentProvider')] + public function testInactiveSupervisorsUseTheSelectedEnvironment( + ?string $horizonEnvironment, + string $expectedQueue, + ): void { + config()->set([ + 'app.env' => 'application', + 'horizon.env' => $horizonEnvironment, + 'horizon.environments' => [ + 'application' => [ + 'supervisor-1' => ['queue' => ['application']], + ], + 'horizon' => [ + 'supervisor-1' => ['queue' => ['horizon']], + ], + ], + ]); + + $master = new MasterSupervisor; + $master->name = 'risa'; + resolve(MasterSupervisorRepository::class)->update($master); + + $response = $this->actingAs(new Fakes\User) + ->get('/horizon/api/masters'); + + $response + ->assertJsonPath('risa.supervisors.0.status', 'inactive') + ->assertJsonPath('risa.supervisors.0.options.queue', $expectedQueue); + } + + /** + * Provide Horizon environment selection cases. + */ + public static function environmentProvider(): array + { + return [ + 'application environment' => [null, 'application'], + 'Horizon environment' => ['horizon', 'horizon'], + ]; + } } diff --git a/tests/Integration/Horizon/Feature/HorizonCommandTest.php b/tests/Integration/Horizon/Feature/HorizonCommandTest.php index 9a6b29bb9..33972bb53 100644 --- a/tests/Integration/Horizon/Feature/HorizonCommandTest.php +++ b/tests/Integration/Horizon/Feature/HorizonCommandTest.php @@ -8,6 +8,7 @@ use Hypervel\Horizon\Contracts\MasterSupervisorRepository; use Hypervel\Horizon\MasterSupervisor; use Hypervel\Tests\Integration\Horizon\IntegrationTestCase; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RunInSeparateProcess; class HorizonCommandTest extends IntegrationTestCase @@ -21,19 +22,30 @@ public function testAlreadyRunningMasterIsASuccessfulNoOp(): void ->assertExitCode(0); } + #[DataProvider('environmentProvider')] #[RunInSeparateProcess] - public function testCommandReturnsTheMasterMonitorStatus(): void - { + public function testCommandUsesTheSelectedEnvironment( + ?string $horizonEnvironment, + ?string $commandEnvironment, + string $expectedEnvironment, + ): void { + config()->set([ + 'app.env' => 'application', + 'horizon.env' => $horizonEnvironment, + ]); + $barrierReached = false; + $observedEnvironment = null; - Coroutine::create(function () use (&$barrierReached): void { + Coroutine::create(function () use (&$barrierReached, &$observedEnvironment): void { try { $masters = app(MasterSupervisorRepository::class); $deadline = hrtime(true) + 10_000_000_000; while (hrtime(true) < $deadline) { - if ($masters->find(MasterSupervisor::name()) !== null) { + if (($master = $masters->find(MasterSupervisor::name())) !== null) { $barrierReached = true; + $observedEnvironment = $master->environment; break; } @@ -45,7 +57,22 @@ public function testCommandReturnsTheMasterMonitorStatus(): void } }); - $this->artisan('horizon')->assertExitCode(0); + $parameters = $commandEnvironment === null ? [] : ['--environment' => $commandEnvironment]; + + $this->artisan('horizon', $parameters)->assertExitCode(0); $this->assertTrue($barrierReached, 'Horizon master never registered before the SIGINT barrier.'); + $this->assertSame($expectedEnvironment, $observedEnvironment); + } + + /** + * Provide Horizon environment precedence cases. + */ + public static function environmentProvider(): array + { + return [ + 'application environment' => [null, null, 'application'], + 'Horizon environment' => ['horizon', null, 'horizon'], + 'command option' => ['horizon', 'command', 'command'], + ]; } } diff --git a/tests/Integration/Horizon/Feature/Listeners/StoreTagsForFailedTest.php b/tests/Integration/Horizon/Feature/Listeners/StoreTagsForFailedTest.php index 1d7dc81ec..526d1432b 100644 --- a/tests/Integration/Horizon/Feature/Listeners/StoreTagsForFailedTest.php +++ b/tests/Integration/Horizon/Feature/Listeners/StoreTagsForFailedTest.php @@ -10,6 +10,7 @@ use Hypervel\Horizon\Events\JobFailed; use Hypervel\Queue\Jobs\Job; use Hypervel\Tests\Integration\Horizon\IntegrationTestCase; +use InvalidArgumentException; use Mockery as m; class StoreTagsForFailedTest extends IntegrationTestCase @@ -34,14 +35,15 @@ public function testTemporaryFailedJobShouldBeDeletedWhenTheMainJobIsDeleted(): $this->app->make(Dispatcher::class)->dispatch($event); } - public function testFailedJobTrimDefaultSurvivesReplaceWholeConfiguration(): void + public function testFailedJobTrimIsRequiredWhenTrimConfigurationIsReplaced(): void { - config()->set('horizon.trim', ['recent' => 60]); - - $tagRepository = m::mock(TagRepository::class); - $tagRepository->shouldReceive('addTemporary')->once()->with(10080, '1', ['failed:foobar'])->andReturn([]); - - $this->instance(TagRepository::class, $tagRepository); + config()->set('horizon.trim', [ + 'recent' => 60, + 'pending' => 60, + 'completed' => 60, + 'recent_failed' => 10080, + 'monitored' => 10080, + ]); $event = new JobFailed( new Exception('job failed'), @@ -50,6 +52,9 @@ public function testFailedJobTrimDefaultSurvivesReplaceWholeConfiguration(): voi ); $event->connection('redis')->queue('default'); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('horizon.trim.failed'); + $this->app->make(Dispatcher::class)->dispatch($event); } } diff --git a/tests/Integration/Horizon/IntegrationTestCase.php b/tests/Integration/Horizon/IntegrationTestCase.php index 6ea5ef95c..96fffe6e0 100644 --- a/tests/Integration/Horizon/IntegrationTestCase.php +++ b/tests/Integration/Horizon/IntegrationTestCase.php @@ -64,6 +64,7 @@ protected function configureHorizonEnvironment(ApplicationContract $app): void 'retry_after' => 90, 'block_for' => null, 'after_commit' => false, + 'migration_batch_size' => -1, ]; $config->set('queue', $queueConfig); } diff --git a/tests/Integration/Horizon/worker.php b/tests/Integration/Horizon/worker.php index 2acfee53e..d018e744c 100644 --- a/tests/Integration/Horizon/worker.php +++ b/tests/Integration/Horizon/worker.php @@ -43,6 +43,7 @@ 'retry_after' => 90, 'block_for' => null, 'after_commit' => false, + 'migration_batch_size' => -1, ], ], ]); From 1a42a39957d5c890d51f25632dacab00731a9be3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:42:10 +0000 Subject: [PATCH 017/109] mail: type Markdown configuration and support null app URLs Read required Markdown theme, paths, extensions, and application names from canonical configuration while retaining partial public mail transport defaults. Allow the HTML and text message layouts to render when the application has no canonical URL instead of forcing a typed string at view time. Add integration coverage for null-URL Markdown rendering and missing Markdown members, update notification channel access, and align the mail and notification examples with typed configuration. --- src/docs/mail.md | 4 +- src/docs/notifications.md | 2 +- .../resources/views/html/layout.blade.php | 2 +- .../resources/views/html/message.blade.php | 4 +- .../resources/views/text/message.blade.php | 4 +- src/mail/src/MailServiceProvider.php | 6 +-- src/mail/src/Mailable.php | 3 +- .../resources/views/email.blade.php | 4 +- .../src/Channels/MailChannel.php | 2 +- .../Mail/Fixtures/message-layout.blade.php | 3 ++ .../Mail/SendingMarkdownMailTest.php | 46 +++++++++++++++++++ tests/Mail/MailServiceProviderTest.php | 14 ++++++ 12 files changed, 78 insertions(+), 16 deletions(-) create mode 100644 tests/Integration/Mail/Fixtures/message-layout.blade.php diff --git a/src/docs/mail.md b/src/docs/mail.md index c2c38c29d..8aaff0f33 100644 --- a/src/docs/mail.md +++ b/src/docs/mail.md @@ -926,7 +926,7 @@ View Order Thanks,
-{{ config('app.name') }} +{{ config()->string('app.name') }} ``` @@ -1683,7 +1683,7 @@ public function boot(): void new Dsn( 'brevo+api', 'default', - config('services.brevo.key') + config()->string('services.brevo.key') ) ); }); diff --git a/src/docs/notifications.md b/src/docs/notifications.md index cfa19617d..ed818213f 100644 --- a/src/docs/notifications.md +++ b/src/docs/notifications.md @@ -906,7 +906,7 @@ View Invoice Thanks,
-{{ config('app.name') }} +{{ config()->string('app.name') }} ``` diff --git a/src/mail/resources/views/html/layout.blade.php b/src/mail/resources/views/html/layout.blade.php index 1ee32093d..bb909d993 100644 --- a/src/mail/resources/views/html/layout.blade.php +++ b/src/mail/resources/views/html/layout.blade.php @@ -1,7 +1,7 @@ -{{ config('app.name') }} +{{ config()->string('app.name') }} diff --git a/src/mail/resources/views/html/message.blade.php b/src/mail/resources/views/html/message.blade.php index 1a874fc26..d2dc7bebf 100644 --- a/src/mail/resources/views/html/message.blade.php +++ b/src/mail/resources/views/html/message.blade.php @@ -2,7 +2,7 @@ {{-- Header --}} -{{ config('app.name') }} +{{ config()->string('app.name') }} @@ -21,7 +21,7 @@ {{-- Footer --}} -© {{ date('Y') }} {{ config('app.name') }}. {{ __('All rights reserved.') }} +© {{ date('Y') }} {{ config()->string('app.name') }}. {{ __('All rights reserved.') }} diff --git a/src/mail/resources/views/text/message.blade.php b/src/mail/resources/views/text/message.blade.php index 80bce2112..693ab1e85 100644 --- a/src/mail/resources/views/text/message.blade.php +++ b/src/mail/resources/views/text/message.blade.php @@ -2,7 +2,7 @@ {{-- Header --}} - {{ config('app.name') }} + {{ config()->string('app.name') }} @@ -21,7 +21,7 @@ {{-- Footer --}} - © {{ date('Y') }} {{ config('app.name') }}. @lang('All rights reserved.') + © {{ date('Y') }} {{ config()->string('app.name') }}. @lang('All rights reserved.') diff --git a/src/mail/src/MailServiceProvider.php b/src/mail/src/MailServiceProvider.php index d266b5fee..54cdf2c4a 100644 --- a/src/mail/src/MailServiceProvider.php +++ b/src/mail/src/MailServiceProvider.php @@ -64,9 +64,9 @@ protected function registerMarkdownRenderer(): void $config = $app->make('config'); return new Markdown($app->make('view'), [ - 'theme' => $config->string('mail.markdown.theme', 'default'), - 'paths' => $config->array('mail.markdown.paths', []), - 'extensions' => $config->array('mail.markdown.extensions', []), + 'theme' => $config->string('mail.markdown.theme'), + 'paths' => $config->array('mail.markdown.paths'), + 'extensions' => $config->array('mail.markdown.extensions'), ]); }); } diff --git a/src/mail/src/Mailable.php b/src/mail/src/Mailable.php index 6fe6b9f53..c0f6a7f17 100644 --- a/src/mail/src/Mailable.php +++ b/src/mail/src/Mailable.php @@ -396,8 +396,7 @@ protected function buildMarkdownText(array $viewData): Closure protected function markdownTheme(): string { return $this->theme ?: Container::getInstance()->make('config')->string( - 'mail.markdown.theme', - 'default' + 'mail.markdown.theme' ); } diff --git a/src/notifications/resources/views/email.blade.php b/src/notifications/resources/views/email.blade.php index a7b8c8647..d711c161b 100644 --- a/src/notifications/resources/views/email.blade.php +++ b/src/notifications/resources/views/email.blade.php @@ -40,7 +40,7 @@ {{ $salutation }} @else @lang('Regards,')
-{{ config('app.name') }} +{{ config()->string('app.name') }} @endif {{-- Subcopy --}} @@ -55,4 +55,4 @@ ) [{{ $displayableActionUrl }}]({{ $actionUrl }}) @endisset - \ No newline at end of file + diff --git a/src/notifications/src/Channels/MailChannel.php b/src/notifications/src/Channels/MailChannel.php index 59fe7044a..22c7efae8 100644 --- a/src/notifications/src/Channels/MailChannel.php +++ b/src/notifications/src/Channels/MailChannel.php @@ -111,7 +111,7 @@ protected function markdownTheme(MailMessage $message): string $config = Container::getInstance() ->make('config'); - return $message->theme ?? $config->string('mail.markdown.theme', 'default'); + return $message->theme ?? $config->string('mail.markdown.theme'); } /** diff --git a/tests/Integration/Mail/Fixtures/message-layout.blade.php b/tests/Integration/Mail/Fixtures/message-layout.blade.php new file mode 100644 index 000000000..deabf4b47 --- /dev/null +++ b/tests/Integration/Mail/Fixtures/message-layout.blade.php @@ -0,0 +1,3 @@ + +# My basic content + diff --git a/tests/Integration/Mail/SendingMarkdownMailTest.php b/tests/Integration/Mail/SendingMarkdownMailTest.php index 84b0916a9..bed6691a2 100644 --- a/tests/Integration/Mail/SendingMarkdownMailTest.php +++ b/tests/Integration/Mail/SendingMarkdownMailTest.php @@ -24,6 +24,11 @@ protected function defineEnvironment(ApplicationContract $app): void 'mailers' => [ 'array' => ['transport' => 'array'], ], + 'markdown' => [ + 'theme' => 'default', + 'paths' => [], + 'extensions' => [], + ], ]); $app->make('view')->addNamespace('mail', __DIR__ . '/Fixtures') @@ -40,6 +45,30 @@ public function testMailIsSent(): void ->assertSeeInHtml('My basic content'); } + public function testMarkdownMailRendersWithANullApplicationUrl(): void + { + config([ + 'app.name' => 'Example App', + 'app.url' => null, + ]); + + Mail::to('test@mail.com')->send(new MarkdownMessageLayoutMailable); + + /** @var Email $email */ + $email = $this->app->make('mailer')->getSymfonyTransport()->messages()[0]->getOriginalMessage(); + $html = $email->getHtmlBody(); + $text = $email->getTextBody(); + + $this->assertIsString($html); + $this->assertIsString($text); + $this->assertStringContainsString('My basic content', $html); + $this->assertStringContainsString('My basic content', $text); + $this->assertStringContainsString('Example App', $html); + $this->assertStringContainsString('Example App', $text); + $this->assertStringContainsString('assertMatchesRegularExpression('/Example App:\s*(?:\r?\n|$)/', $text); + } + public function testMailMayHaveSpecificTextView(): void { $mailable = new MarkdownBasicMailableWithTextView; @@ -211,6 +240,23 @@ public function content(): Content } } +class MarkdownMessageLayoutMailable extends Mailable +{ + public function envelope(): Envelope + { + return new Envelope( + subject: 'My message title', + ); + } + + public function content(): Content + { + return new Content( + markdown: 'message-layout', + ); + } +} + class MarkdownBasicMailableWithTheme extends Mailable { public ?string $theme = 'taylor'; diff --git a/tests/Mail/MailServiceProviderTest.php b/tests/Mail/MailServiceProviderTest.php index a05dccd29..f799ddcaf 100644 --- a/tests/Mail/MailServiceProviderTest.php +++ b/tests/Mail/MailServiceProviderTest.php @@ -10,6 +10,7 @@ use Hypervel\Mail\Markdown; use Hypervel\Support\Facades\Mail; use Hypervel\Testbench\TestCase; +use InvalidArgumentException; use ReflectionProperty; class MailServiceProviderTest extends TestCase @@ -61,4 +62,17 @@ public function testReloadConfigurationPreservesMailFakeAndRefreshesItsWrappedMa $this->assertSame([$mailable], $fake->sent(Mailable::class)->all()); $this->assertNotSame($mailer, $manager->mailer('first')); } + + public function testMarkdownConfigurationRequiresEveryDeclaredMember(): void + { + config(['mail.markdown' => [ + 'theme' => 'default', + 'paths' => [], + ]]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Configuration value for key [mail.markdown.extensions]'); + + $this->app->make(Markdown::class); + } } From 758ab5ecb44ac053adfb0c5bf9e7b7b00fc09100 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:42:18 +0000 Subject: [PATCH 018/109] fortify: preserve nullable passkey configuration Derive relying-party defaults safely when app.url is null and stop eagerly evaluating app.url or app.key fallbacks when dedicated Passkeys settings are present. Move nullable relying-party, origin, and user-handle validation to the domain methods that use them while requiring the configured timeout and verification limiter. Keep the package config, publishable stub, provider bridge, routes, and documentation aligned, with functional coverage for explicit values, null behavior, missing secrets, omitted throttle middleware, and incomplete fixed blocks. --- src/docs/fortify.md | 23 ++++++-- src/fortify/config/fortify.php | 21 ++++++- src/fortify/routes/routes.php | 16 ++--- src/fortify/src/Fortify.php | 2 +- src/fortify/src/FortifyServiceProvider.php | 10 ++-- src/fortify/stubs/fortify.php | 14 ++++- src/passkeys/config/passkeys.php | 16 +++-- src/passkeys/src/Passkeys.php | 8 +-- tests/Fortify/Console/InstallCommandTest.php | 2 + tests/Fortify/FortifyRouteTest.php | 18 ++++++ tests/Fortify/FortifyServiceProviderTest.php | 20 +++++++ tests/Fortify/PasskeyTest.php | 51 +++++++++++++++- tests/Fortify/TestCase.php | 58 +++++++++++++++++-- tests/Passkeys/Feature/PasskeysTest.php | 61 ++++++++++++++++++++ tests/Passkeys/PasskeysGuardTest.php | 20 ++++++- tests/Passkeys/PasskeysRouteTest.php | 15 +++++ tests/Passkeys/TestCase.php | 20 ++++++- 17 files changed, 329 insertions(+), 46 deletions(-) diff --git a/src/docs/fortify.md b/src/docs/fortify.md index 650e83169..aa2eff6e4 100644 --- a/src/docs/fortify.md +++ b/src/docs/fortify.md @@ -447,14 +447,25 @@ Passkey registration and deletion routes require [password confirmation](#passwo Fortify bridges these settings into the standalone Passkeys package: ```php -'passkeys' => [ - 'relying_party_id' => env('PASSKEYS_RELYING_PARTY_ID', parse_url(config('app.url'), PHP_URL_HOST)), - 'allowed_origins' => env_array('PASSKEYS_ALLOWED_ORIGINS', [config('app.url')]), - 'user_handle_secret' => env('PASSKEYS_USER_HANDLE_SECRET', config('app.key')), - 'timeout' => (int) env('PASSKEYS_TIMEOUT', 60000), -], +/** @var null|string $appUrl */ +$appUrl = config('app.url'); +$defaultRelyingPartyId = $appUrl === null ? null : parse_url($appUrl, PHP_URL_HOST); +$defaultAllowedOrigins = $appUrl === null ? [] : [$appUrl]; + +return [ + // ... + + 'passkeys' => [ + 'relying_party_id' => env('PASSKEYS_RELYING_PARTY_ID', $defaultRelyingPartyId), + 'allowed_origins' => env_array('PASSKEYS_ALLOWED_ORIGINS', $defaultAllowedOrigins), + 'user_handle_secret' => env('PASSKEYS_USER_HANDLE_SECRET', config('app.key')), + 'timeout' => (int) env('PASSKEYS_TIMEOUT', 60000), + ], +]; ``` +When your application has no canonical `app.url`, configure `PASSKEYS_RELYING_PARTY_ID` and `PASSKEYS_ALLOWED_ORIGINS` explicitly. Fortify will still boot without them, but a WebAuthn operation will reject the missing values when it first needs them. + Set `PASSKEYS_ALLOWED_ORIGINS` to a comma-separated list when WebAuthn ceremonies should be accepted from more than one origin, such as `https://example.com,https://www.example.com`. If the relying party ID or allowed origins depend on the current request, such as for custom domains or multi-tenant applications, register request-aware callbacks during boot: diff --git a/src/fortify/config/fortify.php b/src/fortify/config/fortify.php index 8a85c4d49..891f6739f 100644 --- a/src/fortify/config/fortify.php +++ b/src/fortify/config/fortify.php @@ -4,6 +4,11 @@ use Hypervel\Fortify\Features; +/** @var null|string $appUrl */ +$appUrl = config('app.url'); +$defaultRelyingPartyId = $appUrl === null ? null : parse_url($appUrl, PHP_URL_HOST); +$defaultAllowedOrigins = $appUrl === null ? [] : [$appUrl]; + return [ 'middleware' => ['web'], 'guard' => null, @@ -19,6 +24,7 @@ 'login' => null, 'two-factor' => '5,1', 'passkeys' => null, + 'verification' => '6,1', ], 'paths' => [ 'login' => null, @@ -70,9 +76,20 @@ 'email-verification' => null, 'password-reset' => null, ], + /* + |-------------------------------------------------------------------------- + | Passkeys + |-------------------------------------------------------------------------- + | + | These settings connect Fortify to Hypervel's passkey support. A null + | relying party ID or user handle secret, and an empty origins list, are + | rejected when a WebAuthn operation first needs the corresponding value. + | + */ + 'passkeys' => [ - 'relying_party_id' => env('PASSKEYS_RELYING_PARTY_ID', parse_url(config('app.url'), PHP_URL_HOST)), - 'allowed_origins' => env_array('PASSKEYS_ALLOWED_ORIGINS', [config('app.url')]), + 'relying_party_id' => env('PASSKEYS_RELYING_PARTY_ID', $defaultRelyingPartyId), + 'allowed_origins' => env_array('PASSKEYS_ALLOWED_ORIGINS', $defaultAllowedOrigins), 'user_handle_secret' => env('PASSKEYS_USER_HANDLE_SECRET', config('app.key')), 'timeout' => (int) env('PASSKEYS_TIMEOUT', 60000), ], diff --git a/src/fortify/routes/routes.php b/src/fortify/routes/routes.php index 637ecca74..bcd7592c5 100644 --- a/src/fortify/routes/routes.php +++ b/src/fortify/routes/routes.php @@ -26,16 +26,16 @@ use Hypervel\Passkeys\Http\Controllers\PasskeyRegistrationController; use Hypervel\Support\Facades\Route; -$middleware = (array) config('fortify.middleware'); -$guard = config('fortify.guard'); +$middleware = config()->array('fortify.middleware'); +$guard = config()->get('fortify.guard'); if (is_string($guard) && $guard !== '') { $middleware[] = 'auth.guard:' . $guard; } Route::group(['middleware' => $middleware], function () { - $enableViews = config('fortify.views'); - $authMiddleware = config('fortify.auth_middleware'); + $enableViews = config()->boolean('fortify.views'); + $authMiddleware = config()->string('fortify.auth_middleware'); // Authentication... if ($enableViews) { @@ -44,10 +44,10 @@ ->name('login'); } - $limiter = config('fortify.limiters.login'); - $twoFactorLimiter = config('fortify.limiters.two-factor'); - $passkeyLimiter = config('fortify.limiters.passkeys'); - $verificationLimiter = config('fortify.limiters.verification', '6,1'); + $limiter = config()->get('fortify.limiters.login'); + $twoFactorLimiter = config()->get('fortify.limiters.two-factor'); + $passkeyLimiter = config()->get('fortify.limiters.passkeys'); + $verificationLimiter = config()->string('fortify.limiters.verification'); Route::post(RoutePath::for('login', '/login'), [AuthenticatedSessionController::class, 'store']) ->middleware(array_filter([ diff --git a/src/fortify/src/Fortify.php b/src/fortify/src/Fortify.php index b9c5d7654..3574d3398 100644 --- a/src/fortify/src/Fortify.php +++ b/src/fortify/src/Fortify.php @@ -89,7 +89,7 @@ public static function redirects(string $redirect, mixed $default = null, ?Reque return (string) (self::config()->get("fortify.redirects.{$redirect}") ?? $default - ?? self::config()->get('fortify.home')); + ?? self::config()->string('fortify.home')); } /** diff --git a/src/fortify/src/FortifyServiceProvider.php b/src/fortify/src/FortifyServiceProvider.php index 882538ea9..d8944e1c8 100644 --- a/src/fortify/src/FortifyServiceProvider.php +++ b/src/fortify/src/FortifyServiceProvider.php @@ -120,13 +120,11 @@ protected function configurePasskeys(): void $this->app->make(ConfigMutationTracker::class)->applyAndRecord( $config, static function (ConfigRepository $config): void { - $appUrl = $config->string('app.url'); - $config->set([ - 'passkeys.relying_party_id' => $config->string('fortify.passkeys.relying_party_id', parse_url($appUrl, PHP_URL_HOST)), - 'passkeys.allowed_origins' => $config->array('fortify.passkeys.allowed_origins', [$appUrl]), - 'passkeys.user_handle_secret' => $config->string('fortify.passkeys.user_handle_secret', $config->string('app.key')), - 'passkeys.timeout' => $config->integer('fortify.passkeys.timeout', 60000), + 'passkeys.relying_party_id' => $config->get('fortify.passkeys.relying_party_id'), + 'passkeys.allowed_origins' => $config->get('fortify.passkeys.allowed_origins'), + 'passkeys.user_handle_secret' => $config->get('fortify.passkeys.user_handle_secret'), + 'passkeys.timeout' => $config->integer('fortify.passkeys.timeout'), ]); }, ); diff --git a/src/fortify/stubs/fortify.php b/src/fortify/stubs/fortify.php index f7a4551c8..eb1b12704 100644 --- a/src/fortify/stubs/fortify.php +++ b/src/fortify/stubs/fortify.php @@ -4,6 +4,11 @@ use Hypervel\Fortify\Features; +/** @var null|string $appUrl */ +$appUrl = config('app.url'); +$defaultRelyingPartyId = $appUrl === null ? null : parse_url($appUrl, PHP_URL_HOST); +$defaultAllowedOrigins = $appUrl === null ? [] : [$appUrl]; + return [ /* |-------------------------------------------------------------------------- @@ -140,6 +145,7 @@ 'login' => 'login', 'two-factor' => '5,1', 'passkeys' => 'passkeys', + 'verification' => '6,1', ], /* @@ -147,13 +153,15 @@ | Passkeys |-------------------------------------------------------------------------- | - | These settings connect Fortify to Hypervel's passkey support. + | These settings connect Fortify to Hypervel's passkey support. A null + | relying party ID or user handle secret, and an empty origins list, are + | rejected when a WebAuthn operation first needs the corresponding value. | */ 'passkeys' => [ - 'relying_party_id' => env('PASSKEYS_RELYING_PARTY_ID', parse_url(config('app.url'), PHP_URL_HOST)), - 'allowed_origins' => env_array('PASSKEYS_ALLOWED_ORIGINS', [config('app.url')]), + 'relying_party_id' => env('PASSKEYS_RELYING_PARTY_ID', $defaultRelyingPartyId), + 'allowed_origins' => env_array('PASSKEYS_ALLOWED_ORIGINS', $defaultAllowedOrigins), 'user_handle_secret' => env('PASSKEYS_USER_HANDLE_SECRET', config('app.key')), 'timeout' => (int) env('PASSKEYS_TIMEOUT', 60000), ], diff --git a/src/passkeys/config/passkeys.php b/src/passkeys/config/passkeys.php index 837d70231..0c94fb349 100644 --- a/src/passkeys/config/passkeys.php +++ b/src/passkeys/config/passkeys.php @@ -2,6 +2,11 @@ declare(strict_types=1); +/** @var null|string $appUrl */ +$appUrl = config('app.url'); +$defaultRelyingPartyId = $appUrl === null ? null : parse_url($appUrl, PHP_URL_HOST); +$defaultAllowedOrigins = $appUrl === null ? [] : [$appUrl]; + return [ /* |-------------------------------------------------------------------------- @@ -10,11 +15,12 @@ | | The relying party ID represents your application in the WebAuthn protocol. | This is typically your domain (e.g., "example.com"). Passkeys are bound - | to this ID and can only be verified on matching domains. + | to this ID and can only be verified on matching domains. When this + | value is null, Passkeys reports the missing ID when it is first used. | */ - 'relying_party_id' => env('PASSKEYS_RELYING_PARTY_ID', parse_url(config('app.url'), PHP_URL_HOST)), + 'relying_party_id' => env('PASSKEYS_RELYING_PARTY_ID', $defaultRelyingPartyId), /* |-------------------------------------------------------------------------- @@ -23,11 +29,12 @@ | | The origins permitted to complete WebAuthn ceremonies. Passkeys bound | to the relying party ID above will only verify when the browser - | reports one of these origins. Defaults to your application URL. + | reports one of these origins. Defaults to your application URL. When + | that URL is null, the empty list is rejected when origins are used. | */ - 'allowed_origins' => env_array('PASSKEYS_ALLOWED_ORIGINS', [config('app.url')]), + 'allowed_origins' => env_array('PASSKEYS_ALLOWED_ORIGINS', $defaultAllowedOrigins), /* |-------------------------------------------------------------------------- @@ -36,6 +43,7 @@ | | A nonempty secret used to derive a stable WebAuthn user handle from each | user model. Set this explicitly if you rotate your application key. + | When both values are null, Passkeys reports the missing secret when used. | */ diff --git a/src/passkeys/src/Passkeys.php b/src/passkeys/src/Passkeys.php index beb26bd10..39f04f017 100644 --- a/src/passkeys/src/Passkeys.php +++ b/src/passkeys/src/Passkeys.php @@ -47,7 +47,7 @@ public static function relyingPartyId(): string $relyingPartyId = $request instanceof Request ? $callback($request) - : self::config()->string('passkeys.relying_party_id'); + : self::config()->get('passkeys.relying_party_id'); if (! is_string($relyingPartyId) || $relyingPartyId === '') { if ($request instanceof Request) { @@ -88,7 +88,7 @@ public static function allowedOrigins(): array $origins = $request instanceof Request ? $callback($request) - : self::config()->array('passkeys.allowed_origins'); + : self::config()->get('passkeys.allowed_origins'); $origins = is_array($origins) ? array_values(array_filter( $origins, @@ -288,9 +288,9 @@ public static function migrationPath(): string */ public static function userHandleSecret(): string { - $secret = self::config()->string('passkeys.user_handle_secret'); + $secret = self::config()->get('passkeys.user_handle_secret'); - if ($secret === '') { + if (! is_string($secret) || $secret === '') { throw new RuntimeException('Passkey user handle secret must not be empty.'); } diff --git a/tests/Fortify/Console/InstallCommandTest.php b/tests/Fortify/Console/InstallCommandTest.php index a69f9c1d2..18c745625 100644 --- a/tests/Fortify/Console/InstallCommandTest.php +++ b/tests/Fortify/Console/InstallCommandTest.php @@ -80,6 +80,8 @@ public function testInstallCommandPublishesFortifyResources(): void $this->assertTrue($config['lowercase_usernames']); $this->assertNotContains(Features::emailVerification(), $config['features']); + $this->assertSame('6,1', $config['limiters']['verification']); + $this->assertSame(60000, $config['passkeys']['timeout']); foreach ($this->publishedSupportFiles() as $file) { $this->assertFileExists($file); diff --git a/tests/Fortify/FortifyRouteTest.php b/tests/Fortify/FortifyRouteTest.php index b91393e80..e371501bb 100644 --- a/tests/Fortify/FortifyRouteTest.php +++ b/tests/Fortify/FortifyRouteTest.php @@ -11,6 +11,7 @@ use Hypervel\Support\Facades\Route; use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Tests\Fortify\Fixtures\Admin; +use InvalidArgumentException; class FortifyRouteTest extends TestCase { @@ -57,7 +58,24 @@ public function testTwoFactorChallengeIsThrottledByDefault(): void $this->assertContains('throttle:5,1', $route->gatherMiddleware()); } + public function testRouteConfigurationRequiresTheVerificationLimiter(): void + { + config(['fortify.limiters' => [ + 'login' => null, + 'two-factor' => '5,1', + 'passkeys' => null, + ]]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Configuration value for key [fortify.limiters.verification]'); + + require dirname(__DIR__, 2) . '/src/fortify/routes/routes.php'; + } + + #[WithConfig('fortify.limiters.login', null)] + #[WithConfig('fortify.limiters.passkeys', null)] #[WithConfig('fortify.limiters.two-factor', '10,1')] + #[WithConfig('fortify.limiters.verification', '6,1')] public function testTwoFactorChallengeThrottleCanBeCustomized(): void { $route = Route::getRoutes()->getByName('two-factor.login.store'); diff --git a/tests/Fortify/FortifyServiceProviderTest.php b/tests/Fortify/FortifyServiceProviderTest.php index 170658aca..a01ee9d07 100644 --- a/tests/Fortify/FortifyServiceProviderTest.php +++ b/tests/Fortify/FortifyServiceProviderTest.php @@ -11,15 +11,18 @@ use Hypervel\Fortify\Contracts\TwoFactorDisabledResponse as TwoFactorDisabledResponseContract; use Hypervel\Fortify\Contracts\TwoFactorEnabledResponse as TwoFactorEnabledResponseContract; use Hypervel\Fortify\Fortify; +use Hypervel\Fortify\FortifyServiceProvider; use Hypervel\Fortify\Http\Responses\TwoFactorDisabledResponse; use Hypervel\Fortify\Http\Responses\TwoFactorEnabledResponse; use Hypervel\Http\JsonResponse; use Hypervel\Http\Request; use Hypervel\Testbench\Attributes\DefineEnvironment; use Hypervel\Tests\Fortify\Fixtures\FixedClock; +use InvalidArgumentException; use OTPHP\TOTP; use Psr\Clock\ClockInterface; use ReflectionClass; +use ReflectionMethod; use Symfony\Component\HttpFoundation\Response; class FortifyServiceProviderTest extends TestCase @@ -81,6 +84,23 @@ public function testTwoFactorAuthenticationProviderUsesFrameworkClock(): void $this->assertTrue($provider->verify($secret, $code)); } + public function testPasskeyBridgeRequiresTheConfiguredTimeout(): void + { + config(['fortify.passkeys' => [ + 'relying_party_id' => null, + 'allowed_origins' => [], + 'user_handle_secret' => null, + ]]); + + $provider = $this->app->getProvider(FortifyServiceProvider::class); + $method = new ReflectionMethod($provider, 'configurePasskeys'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Configuration value for key [fortify.passkeys.timeout]'); + + $method->invoke($provider); + } + #[DefineEnvironment('withTwoFactorAuthentication')] public function testRedirectIfTwoFactorAuthenticatableIsResolvedFreshAfterFlushingScopedInstances(): void { diff --git a/tests/Fortify/PasskeyTest.php b/tests/Fortify/PasskeyTest.php index 43a489a43..16448dcfb 100644 --- a/tests/Fortify/PasskeyTest.php +++ b/tests/Fortify/PasskeyTest.php @@ -18,6 +18,7 @@ use Hypervel\Support\Facades\Route; use Hypervel\Testbench\Attributes\DefineEnvironment; use Hypervel\Testbench\Attributes\WithConfig; +use RuntimeException; #[DefineEnvironment('withPasskeys')] class PasskeyTest extends TestCase @@ -69,7 +70,8 @@ public function testPasskeysRoutesAreNotRegisteredWhenFeatureIsDisabled(): void } #[DefineEnvironment('withPasskeys')] - #[WithConfig('app.url', 'https://example.test')] + #[WithConfig('app.key', null)] + #[WithConfig('app.url', null)] #[WithConfig('fortify.passkeys.allowed_origins', ['https://example.test'])] #[WithConfig('fortify.passkeys.relying_party_id', 'example.test')] #[WithConfig('fortify.passkeys.timeout', 60000)] @@ -80,6 +82,9 @@ public function testPasskeysConfigurationIsSynchronizedWithFortifyConfiguration( $this->assertSame(config('fortify.passkeys.allowed_origins'), config('passkeys.allowed_origins')); $this->assertSame(config('fortify.passkeys.user_handle_secret'), config('passkeys.user_handle_secret')); $this->assertSame(config('fortify.passkeys.timeout'), config('passkeys.timeout')); + $this->assertSame('example.test', Passkeys::relyingPartyId()); + $this->assertSame(['https://example.test'], Passkeys::allowedOrigins()); + $this->assertSame('fortify-passkey-secret', Passkeys::userHandleSecret()); $this->assertNull(config('passkeys.guard')); $this->assertSame(['web'], config('passkeys.middleware')); @@ -93,9 +98,39 @@ public function testPasskeysConfigurationIsSynchronizedWithFortifyConfiguration( $this->assertSame(Fortify::redirects('login', request: $request), Passkeys::redirectTo($request)); } + #[WithConfig('app.key', null)] + #[WithConfig('app.url', null)] + #[WithConfig('fortify.passkeys.allowed_origins', [])] + #[WithConfig('fortify.passkeys.relying_party_id', null)] + #[WithConfig('fortify.passkeys.timeout', 60000)] + #[WithConfig('fortify.passkeys.user_handle_secret', null)] + public function testNullPasskeyConfigurationCrossesTheFortifyBridgeAndFailsAtUse(): void + { + $this->assertNull(config('passkeys.relying_party_id')); + $this->assertSame([], config('passkeys.allowed_origins')); + $this->assertNull(config('passkeys.user_handle_secret')); + $this->assertThrows( + fn () => Passkeys::relyingPartyId(), + RuntimeException::class, + 'Passkey relying party ID must not be empty.', + ); + $this->assertThrows( + fn () => Passkeys::allowedOrigins(), + RuntimeException::class, + 'At least one passkey allowed origin must be configured.', + ); + $this->assertThrows( + fn () => Passkeys::userHandleSecret(), + RuntimeException::class, + 'Passkey user handle secret must not be empty.', + ); + } + #[DefineEnvironment('withPasskeys')] #[WithConfig('fortify.passkeys.allowed_origins', ['https://configured.example.test'])] #[WithConfig('fortify.passkeys.relying_party_id', 'configured.example.test')] + #[WithConfig('fortify.passkeys.timeout', 60000)] + #[WithConfig('fortify.passkeys.user_handle_secret', 'fortify-passkey-secret')] public function testRequestAwarePasskeyConfigurationOverridesFortifyBridgeConfig(): void { RequestContext::set(Request::create('https://dynamic.example.test/passkeys/login/options')); @@ -174,16 +209,28 @@ public function testPackageConfigDoesNotOverwriteAppPasskeyOptions(): void $this->assertSame(['confirmPassword' => false], config('fortify-options.passkeys')); } - public function testPackageConfigReadsPasskeyAllowedOriginsFromEnvironment(): void + public function testPackageConfigUsesExplicitPasskeyValuesWhenApplicationUrlAndKeyAreNull(): void { + config([ + 'app.key' => null, + 'app.url' => null, + ]); + $this->setEnvironmentValue('PASSKEYS_RELYING_PARTY_ID', 'example.com'); $this->setEnvironmentValue('PASSKEYS_ALLOWED_ORIGINS', 'https://example.com, https://www.example.com'); + $this->setEnvironmentValue('PASSKEYS_USER_HANDLE_SECRET', 'explicit-secret'); try { $config = require dirname(__DIR__, 2) . '/src/fortify/config/fortify.php'; + $this->assertSame('example.com', $config['passkeys']['relying_party_id']); $this->assertSame(['https://example.com', 'https://www.example.com'], $config['passkeys']['allowed_origins']); + $this->assertSame('explicit-secret', $config['passkeys']['user_handle_secret']); + $this->assertSame(60000, $config['passkeys']['timeout']); + $this->assertSame('6,1', $config['limiters']['verification']); } finally { + $this->unsetEnvironmentValue('PASSKEYS_RELYING_PARTY_ID'); $this->unsetEnvironmentValue('PASSKEYS_ALLOWED_ORIGINS'); + $this->unsetEnvironmentValue('PASSKEYS_USER_HANDLE_SECRET'); } } diff --git a/tests/Fortify/TestCase.php b/tests/Fortify/TestCase.php index a2516dcff..25a968405 100644 --- a/tests/Fortify/TestCase.php +++ b/tests/Fortify/TestCase.php @@ -41,12 +41,58 @@ protected function defineEnvironment(ApplicationContract $app): void 'app.name' => 'Hypervel Test', 'app.url' => 'https://example.test', 'auth.defaults.guard' => 'web', - 'auth.guards.web' => ['driver' => 'session', 'provider' => 'users', 'passwords' => 'users'], - 'auth.guards.admin' => ['driver' => 'session', 'provider' => 'admins', 'passwords' => 'admins'], - 'auth.providers.users' => ['driver' => 'eloquent', 'model' => $userModel], - 'auth.providers.admins' => ['driver' => 'eloquent', 'model' => Admin::class], - 'auth.passwords.users' => ['provider' => 'users', 'table' => 'password_reset_tokens'], - 'auth.passwords.admins' => ['provider' => 'admins', 'table' => 'admin_password_reset_tokens'], + 'auth.guards.web' => [ + 'driver' => 'session', + 'provider' => 'users', + 'passwords' => 'users', + 'password_timeout' => null, + 'remember' => null, + ], + 'auth.guards.admin' => [ + 'driver' => 'session', + 'provider' => 'admins', + 'passwords' => 'admins', + 'password_timeout' => null, + 'remember' => null, + ], + 'auth.providers.users' => [ + 'driver' => 'eloquent', + 'model' => $userModel, + 'cache' => [ + 'enabled' => false, + 'store' => null, + 'ttl' => 300, + 'prefix' => 'auth_users', + 'tags' => null, + ], + ], + 'auth.providers.admins' => [ + 'driver' => 'eloquent', + 'model' => Admin::class, + 'cache' => [ + 'enabled' => false, + 'store' => null, + 'ttl' => 300, + 'prefix' => 'auth_users', + 'tags' => null, + ], + ], + 'auth.passwords.users' => [ + 'driver' => 'database', + 'provider' => 'users', + 'table' => 'password_reset_tokens', + 'connection' => null, + 'expire' => 60, + 'throttle' => 60, + ], + 'auth.passwords.admins' => [ + 'driver' => 'database', + 'provider' => 'admins', + 'table' => 'admin_password_reset_tokens', + 'connection' => null, + 'expire' => 60, + 'throttle' => 60, + ], 'database.default' => 'testing', 'fortify.home' => '/home', ]); diff --git a/tests/Passkeys/Feature/PasskeysTest.php b/tests/Passkeys/Feature/PasskeysTest.php index 936c16521..b18e10339 100644 --- a/tests/Passkeys/Feature/PasskeysTest.php +++ b/tests/Passkeys/Feature/PasskeysTest.php @@ -49,6 +49,67 @@ public function testConfigDefaultsTheUserHandleSecretToTheApplicationKey(): void $this->assertSame('application-key', $config['user_handle_secret']); } + public function testConfigUsesExplicitPasskeyValuesWhenApplicationUrlAndKeyAreNull(): void + { + config([ + 'app.key' => null, + 'app.url' => null, + ]); + $this->setEnvironmentValue('PASSKEYS_RELYING_PARTY_ID', 'example.com'); + $this->setEnvironmentValue('PASSKEYS_ALLOWED_ORIGINS', 'https://example.com'); + $this->setEnvironmentValue('PASSKEYS_USER_HANDLE_SECRET', 'explicit-secret'); + + try { + $config = require dirname(__DIR__, 3) . '/src/passkeys/config/passkeys.php'; + + $this->assertSame('example.com', $config['relying_party_id']); + $this->assertSame(['https://example.com'], $config['allowed_origins']); + $this->assertSame('explicit-secret', $config['user_handle_secret']); + } finally { + $this->unsetEnvironmentValue('PASSKEYS_RELYING_PARTY_ID'); + $this->unsetEnvironmentValue('PASSKEYS_ALLOWED_ORIGINS'); + $this->unsetEnvironmentValue('PASSKEYS_USER_HANDLE_SECRET'); + } + } + + public function testNullDerivedPasskeyDefaultsFailAtTheDomainBoundary(): void + { + config([ + 'app.key' => null, + 'app.url' => null, + ]); + $this->unsetEnvironmentValue('PASSKEYS_RELYING_PARTY_ID'); + $this->unsetEnvironmentValue('PASSKEYS_ALLOWED_ORIGINS'); + $this->unsetEnvironmentValue('PASSKEYS_USER_HANDLE_SECRET'); + + $config = require dirname(__DIR__, 3) . '/src/passkeys/config/passkeys.php'; + + config([ + 'passkeys.relying_party_id' => $config['relying_party_id'], + 'passkeys.allowed_origins' => $config['allowed_origins'], + 'passkeys.user_handle_secret' => $config['user_handle_secret'], + ]); + + $this->assertNull($config['relying_party_id']); + $this->assertSame([], $config['allowed_origins']); + $this->assertNull($config['user_handle_secret']); + $this->assertThrows( + fn () => Passkeys::relyingPartyId(), + RuntimeException::class, + 'Passkey relying party ID must not be empty.', + ); + $this->assertThrows( + fn () => Passkeys::allowedOrigins(), + RuntimeException::class, + 'At least one passkey allowed origin must be configured.', + ); + $this->assertThrows( + fn () => Passkeys::userHandleSecret(), + RuntimeException::class, + 'Passkey user handle secret must not be empty.', + ); + } + public function testItThrowsWhenTheUserHandleSecretIsEmpty(): void { config(['passkeys.user_handle_secret' => '']); diff --git a/tests/Passkeys/PasskeysGuardTest.php b/tests/Passkeys/PasskeysGuardTest.php index 4cfcf6bad..198325f55 100644 --- a/tests/Passkeys/PasskeysGuardTest.php +++ b/tests/Passkeys/PasskeysGuardTest.php @@ -87,8 +87,24 @@ public function testSelectedGuardProviderScopesPasswordlessPasskeyLookup(): void private function configureAdminGuard(): void { config()->set([ - 'auth.guards.admin' => ['driver' => 'session', 'provider' => 'admins'], - 'auth.providers.admins' => ['driver' => 'eloquent', 'model' => Admin::class], + 'auth.guards.admin' => [ + 'driver' => 'session', + 'provider' => 'admins', + 'passwords' => null, + 'password_timeout' => null, + 'remember' => null, + ], + 'auth.providers.admins' => [ + 'driver' => 'eloquent', + 'model' => Admin::class, + 'cache' => [ + 'enabled' => false, + 'store' => null, + 'ttl' => 300, + 'prefix' => 'auth_users', + 'tags' => null, + ], + ], ]); } diff --git a/tests/Passkeys/PasskeysRouteTest.php b/tests/Passkeys/PasskeysRouteTest.php index 01b6b2fe6..6a051c2a0 100644 --- a/tests/Passkeys/PasskeysRouteTest.php +++ b/tests/Passkeys/PasskeysRouteTest.php @@ -54,4 +54,19 @@ public function testNullGuardConfigDoesNotAddGuardSelectionMiddleware(): void $this->assertNotContains('auth.guard:admin', $middleware); $this->assertContains('guest', $middleware); } + + #[WithConfig('passkeys.throttle', null)] + public function testNullThrottleOmitsThrottleMiddlewareFromLoginAndManagementRoutes(): void + { + foreach (['passkey.login', 'passkey.registration-options'] as $routeName) { + $route = Route::getRoutes()->getByName($routeName); + + $this->assertNotNull($route); + $this->assertFalse(array_any( + $route->middleware(), + static fn (mixed $middleware): bool => is_string($middleware) + && str_starts_with($middleware, 'throttle:'), + )); + } + } } diff --git a/tests/Passkeys/TestCase.php b/tests/Passkeys/TestCase.php index 8b85829aa..448226025 100644 --- a/tests/Passkeys/TestCase.php +++ b/tests/Passkeys/TestCase.php @@ -37,8 +37,24 @@ protected function defineEnvironment(ApplicationContract $app): void 'app.key' => 'base64:' . base64_encode(str_repeat('a', 32)), 'app.url' => 'https://localhost', 'auth.defaults.guard' => 'web', - 'auth.guards.web' => ['driver' => 'session', 'provider' => 'users'], - 'auth.providers.users' => ['driver' => 'eloquent', 'model' => User::class], + 'auth.guards.web' => [ + 'driver' => 'session', + 'provider' => 'users', + 'passwords' => 'users', + 'password_timeout' => null, + 'remember' => null, + ], + 'auth.providers.users' => [ + 'driver' => 'eloquent', + 'model' => User::class, + 'cache' => [ + 'enabled' => false, + 'store' => null, + 'ttl' => 300, + 'prefix' => 'auth_users', + 'tags' => null, + ], + ], 'passkeys.relying_party_id' => 'localhost', 'passkeys.allowed_origins' => ['https://localhost'], 'passkeys.user_handle_secret' => 'test-passkey-secret', From bdfa79d4e6a5f17b101987d08eb657265eb7c58a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:42:24 +0000 Subject: [PATCH 019/109] jwt: normalize flags and make guard TTL intent explicit Cast env-backed blacklist, refresh-issued-at, and subject-lock settings at the config boundary so typed consumers receive real booleans. Give JWT guard records distinct integer, null, and inherit TTL states, validate unsupported values, and require their selected provider. Cover env normalization, inherited and non-expiring tokens, asymmetric signing with a null secret, malformed guard records, and service-provider wiring, and document the guard-only inherit sentinel. --- src/docs/jwt.md | 11 ++++- src/jwt/config/jwt.php | 6 +-- src/jwt/src/JwtGuard.php | 1 + src/jwt/src/JwtManager.php | 2 +- src/jwt/src/JwtServiceProvider.php | 19 ++++++--- tests/Jwt/JwtConfigTest.php | 6 ++- tests/Jwt/JwtGuardTest.php | 3 ++ tests/Jwt/JwtManagerTest.php | 34 +++++++++++++++ tests/Jwt/JwtServiceProviderTest.php | 64 +++++++++++++++++++++++----- 9 files changed, 123 insertions(+), 23 deletions(-) diff --git a/src/docs/jwt.md b/src/docs/jwt.md index e83b2a14e..047872bd6 100644 --- a/src/docs/jwt.md +++ b/src/docs/jwt.md @@ -119,6 +119,9 @@ To use JWT authentication, configure an auth guard that uses the `jwt` driver: 'api' => [ 'driver' => 'jwt', 'provider' => 'users', + 'passwords' => null, + 'password_timeout' => null, + 'ttl' => 'inherit', ], ], ``` @@ -233,24 +236,30 @@ Set this value to `null` to issue tokens without an `exp` claim: 'ttl' => null, ``` -You may also configure a different TTL per guard: +Every JWT guard declares its own `ttl` member. Set it to `inherit` to use the global `jwt.ttl` value, an integer to override that value in minutes, or `null` to issue non-expiring tokens from that guard: ```php 'guards' => [ 'customers' => [ 'driver' => 'jwt', 'provider' => 'customers', + 'passwords' => null, + 'password_timeout' => null, 'ttl' => 15, ], 'devices' => [ 'driver' => 'jwt', 'provider' => 'devices', + 'passwords' => null, + 'password_timeout' => null, 'ttl' => null, ], ], ``` +The `inherit` value is only supported by JWT guard records. The global `jwt.ttl` option accepts an integer or `null`. + For one token-producing operation, use `setTTL`: ```php diff --git a/src/jwt/config/jwt.php b/src/jwt/config/jwt.php index 592dced40..e6e7460f0 100644 --- a/src/jwt/config/jwt.php +++ b/src/jwt/config/jwt.php @@ -233,7 +233,7 @@ | */ - 'blacklist_enabled' => env('JWT_BLACKLIST_ENABLED', false), + 'blacklist_enabled' => (bool) env('JWT_BLACKLIST_ENABLED', false), /* |-------------------------------------------------------------------------- @@ -245,7 +245,7 @@ | */ - 'refresh_iat' => env('JWT_REFRESH_IAT', false), + 'refresh_iat' => (bool) env('JWT_REFRESH_IAT', false), /* |-------------------------------------------------------------------------- @@ -257,7 +257,7 @@ | */ - 'lock_subject' => env('JWT_LOCK_SUBJECT', true), + 'lock_subject' => (bool) env('JWT_LOCK_SUBJECT', true), /* |-------------------------------------------------------------------------- diff --git a/src/jwt/src/JwtGuard.php b/src/jwt/src/JwtGuard.php index 1fdaa5319..806d61710 100644 --- a/src/jwt/src/JwtGuard.php +++ b/src/jwt/src/JwtGuard.php @@ -60,6 +60,7 @@ public function __construct( protected ClaimFactory $claimFactory, protected Parser $parser, protected Container $app, + // Keep the direct-construction default aligned with config/jwt.php. protected ?int $ttl = 120, ) { $this->provider = $provider; diff --git a/src/jwt/src/JwtManager.php b/src/jwt/src/JwtManager.php index d3be16b08..c5551c64c 100644 --- a/src/jwt/src/JwtManager.php +++ b/src/jwt/src/JwtManager.php @@ -60,7 +60,7 @@ public function reloadConfiguration(): void */ public function createLcobucciDriver(): Lcobucci { - $class = $this->config->string('jwt.providers.jwt', Lcobucci::class); + $class = $this->config->string('jwt.providers.jwt'); if (! is_a($class, Lcobucci::class, true)) { throw new RuntimeException('JWT provider must be an instance of ' . Lcobucci::class); diff --git a/src/jwt/src/JwtServiceProvider.php b/src/jwt/src/JwtServiceProvider.php index dba01e57e..fddb937ae 100644 --- a/src/jwt/src/JwtServiceProvider.php +++ b/src/jwt/src/JwtServiceProvider.php @@ -17,6 +17,7 @@ use Hypervel\Jwt\Http\Parser\Parser; use Hypervel\Jwt\Storage\TaggedCache; use Hypervel\Support\ServiceProvider; +use InvalidArgumentException; use RuntimeException; class JwtServiceProvider extends ServiceProvider implements ReloadsConfiguration @@ -56,7 +57,7 @@ public function register(): void $this->app->singleton(BlacklistContract::class, function ($app) { $config = $app->make('config'); - $storageClass = $config->string('jwt.providers.storage', TaggedCache::class); + $storageClass = $config->string('jwt.providers.storage'); $storage = match ($storageClass) { TaggedCache::class => new TaggedCache($this->cacheStoreForJwtBlacklist( $app, @@ -131,14 +132,20 @@ protected function registerJwtGuard(): void { $this->callAfterResolving(AuthManager::class, function (AuthManager $authManager) { $authManager->extend('jwt', function ($app, $name, $config) use ($authManager) { - /** @var null|int $ttl */ - $ttl = array_key_exists('ttl', $config) - ? $config['ttl'] - : $app->make('config')->get('jwt.ttl'); + $ttl = $config['ttl']; + + if ($ttl === 'inherit') { + /** @var null|int $ttl */ + $ttl = $app->make('config')->get('jwt.ttl'); + } elseif (! is_int($ttl) && $ttl !== null) { + throw new InvalidArgumentException( + "Auth guard [{$name}] declares an invalid jwt ttl. Use an integer, null, or 'inherit'." + ); + } $guard = new JwtGuard( name: $name, - provider: $authManager->createUserProvider($config['provider'] ?? null), + provider: $authManager->createUserProvider($config['provider']), jwtManager: $app->make('jwt'), claimFactory: $app->make(ClaimFactory::class), parser: $app->make(Parser::class), diff --git a/tests/Jwt/JwtConfigTest.php b/tests/Jwt/JwtConfigTest.php index 4e2546492..3be2601fb 100644 --- a/tests/Jwt/JwtConfigTest.php +++ b/tests/Jwt/JwtConfigTest.php @@ -137,8 +137,9 @@ public function testNewJwtOptionsAreLoadedFromEnvironment(): void { $originalValues = $this->setEnvironmentVariables([ 'JWT_ISSUER' => 'https://api.example.test', - 'JWT_REFRESH_IAT' => 'true', - 'JWT_LOCK_SUBJECT' => 'false', + 'JWT_BLACKLIST_ENABLED' => '1', + 'JWT_REFRESH_IAT' => '1', + 'JWT_LOCK_SUBJECT' => '0', 'JWT_TOKEN' => 'api_token', ]); @@ -148,6 +149,7 @@ public function testNewJwtOptionsAreLoadedFromEnvironment(): void $config = require dirname(__DIR__, 2) . '/src/jwt/config/jwt.php'; $this->assertSame('https://api.example.test', $config['issuer']); + $this->assertTrue($config['blacklist_enabled']); $this->assertTrue($config['refresh_iat']); $this->assertFalse($config['lock_subject']); $this->assertSame('api_token', $config['token']); diff --git a/tests/Jwt/JwtGuardTest.php b/tests/Jwt/JwtGuardTest.php index 5ff608653..9b7504614 100644 --- a/tests/Jwt/JwtGuardTest.php +++ b/tests/Jwt/JwtGuardTest.php @@ -1004,6 +1004,9 @@ protected function createAuthTestContainer(): Application 'jwt' => [ 'driver' => 'jwt', 'provider' => 'users', + 'passwords' => null, + 'password_timeout' => null, + 'ttl' => 'inherit', ], ], 'providers' => [ diff --git a/tests/Jwt/JwtManagerTest.php b/tests/Jwt/JwtManagerTest.php index 58f60a63f..1ebc1d375 100644 --- a/tests/Jwt/JwtManagerTest.php +++ b/tests/Jwt/JwtManagerTest.php @@ -16,6 +16,7 @@ use Hypervel\Jwt\Exceptions\TokenInvalidException; use Hypervel\Jwt\JwtManager; use Hypervel\Jwt\Providers\Lcobucci; +use Hypervel\Jwt\Providers\Provider; use Hypervel\Jwt\Validations\ExpiredClaim; use Hypervel\Jwt\Validations\NotBeforeClaim; use Hypervel\Jwt\Validations\RequiredClaims; @@ -117,6 +118,39 @@ public function testEncodeDoesNotAddJtiWhenBlacklistIsDisabled(): void $this->assertSame($token, $this->createManager()->encode($payload)); } + public function testNullSecretSupportsAsymmetricSigning(): void + { + $application = new Application; + $application->instance('config', new Repository([ + 'jwt' => [ + 'blacklist_enabled' => false, + 'driver' => 'lcobucci', + 'providers' => [ + 'jwt' => Lcobucci::class, + ], + 'secret' => null, + 'algo' => Provider::ALGO_RS256, + 'keys' => [ + 'private' => file_get_contents(__DIR__ . '/Fixtures/keys/id_rsa'), + 'public' => file_get_contents(__DIR__ . '/Fixtures/keys/id_rsa.pub'), + 'passphrase' => null, + ], + ], + ])); + + $manager = new JwtManager($application, m::mock(ClaimFactory::class)); + $token = $manager->encode([ + 'sub' => 1, + 'iat' => $this->testNowTimestamp, + 'custom' => 'value', + ]); + $payload = $manager->decode($token, validate: false); + + $this->assertSame('1', $payload['sub']); + $this->assertSame($this->testNowTimestamp, $payload['iat']); + $this->assertSame('value', $payload['custom']); + } + public function testConstructorDoesNotResolveBlacklistWhenBlacklistIsDisabled(): void { $container = m::mock(Container::class); diff --git a/tests/Jwt/JwtServiceProviderTest.php b/tests/Jwt/JwtServiceProviderTest.php index 7d07ad9a7..47537a8f7 100644 --- a/tests/Jwt/JwtServiceProviderTest.php +++ b/tests/Jwt/JwtServiceProviderTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Jwt; +use ErrorException; use Hypervel\Auth\AuthManager; use Hypervel\Cache\Repository as CacheRepository; use Hypervel\Cache\StackStore; @@ -30,6 +31,7 @@ use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Facades\Date; use Hypervel\Testbench\TestCase; +use InvalidArgumentException; use Mockery as m; use ReflectionProperty; use RuntimeException; @@ -66,6 +68,16 @@ public function testJwtMiddlewareAliasesAreNotRegistered(): void $this->assertArrayNotHasKey('jwt.check', $middleware); } + public function testShippedJwtGuardInheritsGlobalTtl(): void + { + $this->app->make('config')->set('jwt.ttl', 45); + + /** @var JwtGuard $guard */ + $guard = $this->app->make(AuthManager::class)->guard('jwt'); + + $this->assertSame(45, $guard->getTTL()); + } + public function testGuardReceivesExplicitNullTtlAndDispatcher(): void { $config = $this->app->make('config'); @@ -73,6 +85,8 @@ public function testGuardReceivesExplicitNullTtlAndDispatcher(): void $config->set('auth.guards.jwt', [ 'driver' => 'jwt', 'provider' => 'users', + 'passwords' => null, + 'password_timeout' => null, 'ttl' => null, ]); $config->set('auth.providers.users', [ @@ -99,6 +113,8 @@ public function testGuardReceivesNumericPerGuardTtl(): void $config->set('auth.guards.jwt', [ 'driver' => 'jwt', 'provider' => 'users', + 'passwords' => null, + 'password_timeout' => null, 'ttl' => 15, ]); $config->set('auth.providers.users', [ @@ -117,6 +133,39 @@ public function testGuardReceivesNumericPerGuardTtl(): void $this->assertSame(15, $guard->getTTL()); } + public function testGuardRequiresTtlMember(): void + { + $this->app->make('config')->set('auth.guards.jwt', [ + 'driver' => 'jwt', + 'provider' => 'users', + 'passwords' => null, + 'password_timeout' => null, + ]); + + $this->expectException(ErrorException::class); + $this->expectExceptionMessage('Undefined array key "ttl"'); + + $this->app->make(AuthManager::class)->guard('jwt'); + } + + public function testGuardRejectsUnsupportedTtlValue(): void + { + $this->app->make('config')->set('auth.guards.customers', [ + 'driver' => 'jwt', + 'provider' => 'users', + 'passwords' => null, + 'password_timeout' => null, + 'ttl' => 'forever', + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + "Auth guard [customers] declares an invalid jwt ttl. Use an integer, null, or 'inherit'." + ); + + $this->app->make(AuthManager::class)->guard('customers'); + } + public function testTaggedCacheStorageUsesCacheStore(): void { $config = $this->app->make('config'); @@ -323,23 +372,18 @@ public function testBlacklistReceivesNullRefreshTtl(): void $this->assertTrue($storage->foreverCalled); } - public function testOmittedStorageProviderUsesTheTaggedCacheDefault(): void + public function testStorageProviderIsRequired(): void { $config = $this->app->make('config'); $config->set('jwt.providers', ['jwt' => Lcobucci::class]); $config->set('jwt.blacklist_enabled', true); - - $repository = m::mock(CacheRepository::class); - $repository->shouldReceive('supportsTags')->once()->andReturnTrue(); - $repository->shouldReceive('getStore')->once()->andReturn($this->taggableStore(TagMode::All)); - $cache = m::mock(); - $cache->shouldReceive('store')->once()->withNoArgs()->andReturn($repository); - - $this->app->instance('cache', $cache); $this->app->forgetInstance(BlacklistContract::class); $this->app->forgetInstance('jwt'); - $this->assertInstanceOf(JwtManager::class, $this->app->make('jwt')); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Configuration value for key [jwt.providers.storage] must be a string'); + + $this->app->make('jwt'); } public function testReloadConfigurationRefreshesResolvedJwtServices(): void From 1cfb330228714c3b25fb4141e5454e0c784acad5 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:42:30 +0000 Subject: [PATCH 020/109] permission: type configuration and preserve nullable identifiers Read required permission models, table maps, cache settings, teams flags, and resolver configuration through typed APIs. Access nullable pivot and default-model members explicitly before applying their documented role_id, permission_id, or authenticated-model behavior so omission cannot masquerade as null. Update package migrations and team-upgrade tooling together, with coverage for null pivots, assigned-model fallback, cached roles, team behavior, and partitioned database schemas. --- src/permission/config/permission.php | 7 +- ..._07_02_000000_create_permission_tables.php | 24 ++++--- .../migrations/add_teams_fields.php.stub | 26 ++++--- .../src/Commands/UpgradeForTeamsCommand.php | 2 +- src/permission/src/Guard.php | 2 +- src/permission/src/PermissionRegistrar.php | 26 +++---- .../src/PermissionServiceProvider.php | 4 +- .../Database/PermissionPartitionTest.php | 42 ++++++++++-- tests/Permission/GuardTest.php | 16 ++++- .../Integration/PermissionRegistrarTest.php | 14 ++++ tests/Permission/Support/ConfigTest.php | 16 ++++- tests/Permission/TestCase.php | 67 +++++++++++++++++-- .../Traits/HasAssignedModelsTest.php | 11 +++ tests/Permission/Traits/HasRolesTest.php | 16 ++++- 14 files changed, 216 insertions(+), 57 deletions(-) diff --git a/src/permission/config/permission.php b/src/permission/config/permission.php index 97bec4613..fd06eb617 100644 --- a/src/permission/config/permission.php +++ b/src/permission/config/permission.php @@ -20,12 +20,14 @@ 'role' => Role::class, /* - * The app-owned team model used by the teams feature. + * The app-owned team model used by the teams feature. Set to null when + * teams are disabled or the application does not expose a team model. */ 'team' => null, /* * The model used when raw IDs are passed to reverse-assignment helpers. + * Set to null to use the authenticated guard's user model. */ 'default_model' => null, ], @@ -39,6 +41,9 @@ ], 'column_names' => [ + /* + * Set these pivot keys to null to use role_id and permission_id. + */ 'role_pivot_key' => null, 'permission_pivot_key' => null, 'model_morph_key' => 'model_id', diff --git a/src/permission/database/migrations/2025_07_02_000000_create_permission_tables.php b/src/permission/database/migrations/2025_07_02_000000_create_permission_tables.php index f8a5ebc17..61bb01830 100644 --- a/src/permission/database/migrations/2025_07_02_000000_create_permission_tables.php +++ b/src/permission/database/migrations/2025_07_02_000000_create_permission_tables.php @@ -12,13 +12,15 @@ */ public function up(): void { - $teams = (bool) config('permission.teams'); - $tableNames = (array) config('permission.table_names'); - $columnNames = (array) config('permission.column_names'); - $pivotRole = $columnNames['role_pivot_key'] ?? 'role_id'; - $pivotPermission = $columnNames['permission_pivot_key'] ?? 'permission_id'; - $teamForeignKey = $columnNames['team_foreign_key'] ?? 'team_id'; - $modelMorphKey = $columnNames['model_morph_key'] ?? 'model_id'; + $teams = config()->boolean('permission.teams'); + $tableNames = config()->array('permission.table_names'); + $columnNames = config()->array('permission.column_names'); + $pivotRole = $columnNames['role_pivot_key']; + $pivotRole ??= 'role_id'; + $pivotPermission = $columnNames['permission_pivot_key']; + $pivotPermission ??= 'permission_id'; + $teamForeignKey = $columnNames['team_foreign_key']; + $modelMorphKey = $columnNames['model_morph_key']; throw_if($tableNames === [], 'Error: config/permission.php not loaded. Run [php artisan config:clear] and try again.'); throw_if($teams && $teamForeignKey === '', 'Error: team_foreign_key on config/permission.php not loaded. Run [php artisan config:clear] and try again.'); @@ -112,9 +114,11 @@ public function up(): void $table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary'); }); + $cacheStore = config()->string('permission.cache.store'); + app('cache') - ->store(config('permission.cache.store') !== 'default' ? config('permission.cache.store') : null) - ->forget(config('permission.cache.keys.roles')); + ->store($cacheStore !== 'default' ? $cacheStore : null) + ->forget(config()->string('permission.cache.keys.roles')); } /** @@ -122,7 +126,7 @@ public function up(): void */ public function down(): void { - $tableNames = (array) config('permission.table_names'); + $tableNames = config()->array('permission.table_names'); throw_if($tableNames === [], 'Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.'); diff --git a/src/permission/database/migrations/add_teams_fields.php.stub b/src/permission/database/migrations/add_teams_fields.php.stub index 684ff5a46..8d12043ce 100644 --- a/src/permission/database/migrations/add_teams_fields.php.stub +++ b/src/permission/database/migrations/add_teams_fields.php.stub @@ -13,13 +13,15 @@ return new class extends Migration { */ public function up(): void { - $teams = (bool) config('permission.teams'); - $tableNames = (array) config('permission.table_names'); - $columnNames = (array) config('permission.column_names'); - $pivotRole = $columnNames['role_pivot_key'] ?? 'role_id'; - $pivotPermission = $columnNames['permission_pivot_key'] ?? 'permission_id'; - $teamForeignKey = $columnNames['team_foreign_key'] ?? 'team_id'; - $modelMorphKey = $columnNames['model_morph_key'] ?? 'model_id'; + $teams = config()->boolean('permission.teams'); + $tableNames = config()->array('permission.table_names'); + $columnNames = config()->array('permission.column_names'); + $pivotRole = $columnNames['role_pivot_key']; + $pivotRole ??= 'role_id'; + $pivotPermission = $columnNames['permission_pivot_key']; + $pivotPermission ??= 'permission_id'; + $teamForeignKey = $columnNames['team_foreign_key']; + $modelMorphKey = $columnNames['model_morph_key']; if (! $teams) { return; @@ -82,13 +84,17 @@ return new class extends Migration { }); } + $cacheStore = config()->string('permission.cache.store'); + app('cache') - ->store(config('permission.cache.store') !== 'default' ? config('permission.cache.store') : null) - ->forget(config('permission.cache.keys.roles')); + ->store($cacheStore !== 'default' ? $cacheStore : null) + ->forget(config()->string('permission.cache.keys.roles')); } /** * Reverse the migrations. */ - public function down(): void {} + public function down(): void + { + } }; diff --git a/src/permission/src/Commands/UpgradeForTeamsCommand.php b/src/permission/src/Commands/UpgradeForTeamsCommand.php index 87e77dd30..6a4fa4dfd 100644 --- a/src/permission/src/Commands/UpgradeForTeamsCommand.php +++ b/src/permission/src/Commands/UpgradeForTeamsCommand.php @@ -23,7 +23,7 @@ class UpgradeForTeamsCommand extends Command */ public function handle(ConfigRepository $config): int { - if (! $config->boolean('permission.teams', false)) { + if (! $config->boolean('permission.teams')) { $this->error('Teams feature is disabled in your permission.php file.'); $this->warn('Please enable the teams setting in your configuration.'); diff --git a/src/permission/src/Guard.php b/src/permission/src/Guard.php index 51af214b5..bdabb7552 100644 --- a/src/permission/src/Guard.php +++ b/src/permission/src/Guard.php @@ -136,7 +136,7 @@ public static function normalizeName(?string $guard): ?string */ protected static function guards(): array { - return self::config()->array('auth.guards', []); + return self::config()->array('auth.guards'); } /** diff --git a/src/permission/src/PermissionRegistrar.php b/src/permission/src/PermissionRegistrar.php index 298e6ff48..1421edbae 100644 --- a/src/permission/src/PermissionRegistrar.php +++ b/src/permission/src/PermissionRegistrar.php @@ -235,34 +235,34 @@ public function ensureModelMatchesPartition(Model $model, PermissionPartition $p public function initializeCache(): void { /** @var class-string $permissionClass */ - $permissionClass = $this->config->string('permission.models.permission', Permission::class); + $permissionClass = $this->config->string('permission.models.permission'); /** @var class-string $roleClass */ - $roleClass = $this->config->string('permission.models.role', Role::class); + $roleClass = $this->config->string('permission.models.role'); /** @var null|class-string $teamClass */ $teamClass = $this->config->get('permission.models.team'); /** @var class-string $teamResolverClass */ - $teamResolverClass = $this->config->string('permission.team_resolver', DefaultTeamResolver::class); + $teamResolverClass = $this->config->string('permission.team_resolver'); $this->permissionClass = $permissionClass; $this->roleClass = $roleClass; $this->teamClass = $teamClass; $this->teamResolver = $this->app->make($teamResolverClass); - $this->cacheExpirationTime = $this->config->integer('permission.cache.expiration_seconds', 86400); - $this->teams = $this->config->boolean('permission.teams', false); - $this->teamsKey = $this->config->string('permission.column_names.team_foreign_key', 'team_id'); + $this->cacheExpirationTime = $this->config->integer('permission.cache.expiration_seconds'); + $this->teams = $this->config->boolean('permission.teams'); + $this->teamsKey = $this->config->string('permission.column_names.team_foreign_key'); - $this->cacheKey = $this->config->string('permission.cache.keys.roles', 'hypervel.permission.cache.roles'); - $this->modelRolesCacheKeyPrefix = $this->config->string('permission.cache.keys.model_roles', self::MODEL_ROLES_CACHE_KEY_PREFIX); - $this->modelPermissionsCacheKeyPrefix = $this->config->string('permission.cache.keys.model_permissions', self::MODEL_PERMISSIONS_CACHE_KEY_PREFIX); - $this->modelCacheTokenKey = $this->config->string('permission.cache.keys.model_token', self::MODEL_CACHE_TOKEN_KEY); + $this->cacheKey = $this->config->string('permission.cache.keys.roles'); + $this->modelRolesCacheKeyPrefix = $this->config->string('permission.cache.keys.model_roles'); + $this->modelPermissionsCacheKeyPrefix = $this->config->string('permission.cache.keys.model_permissions'); + $this->modelCacheTokenKey = $this->config->string('permission.cache.keys.model_token'); $pivotRole = $this->config->get('permission.column_names.role_pivot_key'); $pivotPermission = $this->config->get('permission.column_names.permission_pivot_key'); $this->pivotRole = is_string($pivotRole) && $pivotRole !== '' ? $pivotRole : 'role_id'; $this->pivotPermission = is_string($pivotPermission) && $pivotPermission !== '' ? $pivotPermission : 'permission_id'; - $cacheStore = $this->config->string('permission.cache.store', 'default'); + $cacheStore = $this->config->string('permission.cache.store'); $this->cacheStoreName = $cacheStore === 'default' ? null : $cacheStore; $this->assignmentPivotClasses = []; @@ -285,7 +285,7 @@ protected function validateModelClasses(): void */ protected function validateCacheColumnExclusions(): void { - $except = $this->config->array('permission.cache.column_names_except', ['created_at', 'updated_at', 'deleted_at']); + $except = $this->config->array('permission.cache.column_names_except'); $partitionColumn = static::partitionColumn(); $roleColumns = [(new $this->roleClass)->getKeyName(), 'name', 'guard_name']; $permissionColumns = [(new $this->permissionClass)->getKeyName(), 'name', 'guard_name']; @@ -1399,7 +1399,7 @@ protected function getRolesForCache(): Collection */ private function getSerializedPermissionsForCache(): array { - $except = $this->config->array('permission.cache.column_names_except', ['created_at', 'updated_at', 'deleted_at']); + $except = $this->config->array('permission.cache.column_names_except'); $hasDeniedRolePermissions = false; $partition = $this->resolvePartition(); diff --git a/src/permission/src/PermissionServiceProvider.php b/src/permission/src/PermissionServiceProvider.php index d16089611..eb1f280b8 100644 --- a/src/permission/src/PermissionServiceProvider.php +++ b/src/permission/src/PermissionServiceProvider.php @@ -199,7 +199,7 @@ protected function registerGateHook(): void $this->callAfterResolving(GateContract::class, function (GateContract $gate): void { $config = $this->app->make('config'); - if (! $config->boolean('permission.register_permission_check_method', true)) { + if (! $config->boolean('permission.register_permission_check_method')) { return; } @@ -225,7 +225,7 @@ protected function registerAbout(): void AboutCommand::add('Hypervel Permissions', static function () use ($features, $config): array { $enabledFeatures = Collection::make($features) - ->filter(fn (?string $feature): bool => $feature === null || $config->boolean("permission.{$feature}", false)) + ->filter(fn (?string $feature): bool => $feature === null || $config->boolean("permission.{$feature}")) ->keys(); if (PermissionRegistrar::partitioningEnabled()) { diff --git a/tests/Integration/Database/PermissionPartitionTest.php b/tests/Integration/Database/PermissionPartitionTest.php index 3452fc2f2..e0d3fd728 100644 --- a/tests/Integration/Database/PermissionPartitionTest.php +++ b/tests/Integration/Database/PermissionPartitionTest.php @@ -51,15 +51,45 @@ protected function defineEnvironment(ApplicationContract $app): void ); $config->set([ - 'permission.models.permission' => PartitionedPermission::class, - 'permission.models.role' => PartitionedRole::class, - 'permission.models.default_model' => GlobalPartitionUser::class, + 'permission.models' => [ + 'permission' => PartitionedPermission::class, + 'role' => PartitionedRole::class, + 'team' => null, + 'default_model' => GlobalPartitionUser::class, + ], 'permission.column_names.model_morph_key' => 'model_test_id', + 'permission.column_names.team_foreign_key' => 'team_id', 'permission.column_names.role_pivot_key' => 'role_test_id', 'permission.column_names.permission_pivot_key' => 'permission_test_id', - 'permission.cache.store' => 'array', - 'auth.guards.web' => ['driver' => 'session', 'provider' => 'users'], - 'auth.providers.users' => ['driver' => 'eloquent', 'model' => GlobalPartitionUser::class], + 'permission.cache' => [ + 'expiration_seconds' => 86400, + 'store' => 'array', + 'keys' => [ + 'roles' => 'hypervel.permission.cache.roles', + 'model_roles' => 'hypervel.permission.cache.model.roles', + 'model_permissions' => 'hypervel.permission.cache.model.permissions', + 'model_token' => 'hypervel.permission.cache.model.token', + ], + 'column_names_except' => ['created_at', 'updated_at', 'deleted_at'], + ], + 'auth.guards.web' => [ + 'driver' => 'session', + 'provider' => 'users', + 'passwords' => 'users', + 'password_timeout' => null, + 'remember' => null, + ], + 'auth.providers.users' => [ + 'driver' => 'eloquent', + 'model' => GlobalPartitionUser::class, + 'cache' => [ + 'enabled' => false, + 'store' => null, + 'ttl' => 300, + 'prefix' => 'auth_users', + 'tags' => null, + ], + ], 'cache.default' => 'array', 'cache.stores.array' => ['driver' => 'array'], ]); diff --git a/tests/Permission/GuardTest.php b/tests/Permission/GuardTest.php index 53bd97fbb..efb3585b5 100644 --- a/tests/Permission/GuardTest.php +++ b/tests/Permission/GuardTest.php @@ -65,7 +65,13 @@ public function testZeroGuardNamesRemainStringIdentifiers(): void $this->assertSame('0', Guard::getDefaultName($user)); $this->app->make('config')->set('auth.guards', [ - '0' => ['driver' => 'session', 'provider' => 'users'], + '0' => [ + 'driver' => 'session', + 'provider' => 'users', + 'passwords' => null, + 'password_timeout' => null, + 'remember' => null, + ], ]); Guard::flushState(); @@ -104,7 +110,13 @@ public function testZeroPassportGuardRunsTheClientCompatibilityCheck(): void $this->setUpPassport(); $this->app->make('config')->set([ - 'auth.guards.api' => ['driver' => 'session', 'provider' => 'users'], + 'auth.guards.api' => [ + 'driver' => 'session', + 'provider' => 'users', + 'passwords' => null, + 'password_timeout' => null, + 'remember' => null, + ], 'auth.guards.0' => ['driver' => 'passport', 'provider' => 'users'], ]); diff --git a/tests/Permission/Integration/PermissionRegistrarTest.php b/tests/Permission/Integration/PermissionRegistrarTest.php index 6a564d497..c141d4943 100644 --- a/tests/Permission/Integration/PermissionRegistrarTest.php +++ b/tests/Permission/Integration/PermissionRegistrarTest.php @@ -259,6 +259,20 @@ public function testInitializeCacheAcceptsDefaultColumnExclusions(): void $this->assertSame(HypervelPermission::class, $registrar->getPermissionClass()); } + public function testNullPivotKeysUseConventionalColumnNames(): void + { + config([ + 'permission.column_names.role_pivot_key' => null, + 'permission.column_names.permission_pivot_key' => null, + ]); + + $registrar = $this->app->make(PermissionRegistrar::class); + $registrar->initializeCache(); + + $this->assertSame('role_id', $registrar->pivotRole); + $this->assertSame('permission_id', $registrar->pivotPermission); + } + public function testInitializeCacheRejectsRequiredDefaultModelColumns(): void { $this->app->make('config')->set( diff --git a/tests/Permission/Support/ConfigTest.php b/tests/Permission/Support/ConfigTest.php index 598472ac1..befe7f64a 100644 --- a/tests/Permission/Support/ConfigTest.php +++ b/tests/Permission/Support/ConfigTest.php @@ -17,8 +17,20 @@ protected function defineEnvironment(ApplicationContract $app): void $app->make('config')->set([ 'auth.defaults.guard' => 'web', - 'auth.guards.web' => ['driver' => 'session', 'provider' => 'users'], - 'auth.guards.admin' => ['driver' => 'session', 'provider' => 'admins'], + 'auth.guards.web' => [ + 'driver' => 'session', + 'provider' => 'users', + 'passwords' => 'users', + 'password_timeout' => null, + 'remember' => null, + ], + 'auth.guards.admin' => [ + 'driver' => 'session', + 'provider' => 'admins', + 'passwords' => null, + 'password_timeout' => null, + 'remember' => null, + ], ]); } diff --git a/tests/Permission/TestCase.php b/tests/Permission/TestCase.php index b311358c7..2bd2dbafc 100644 --- a/tests/Permission/TestCase.php +++ b/tests/Permission/TestCase.php @@ -76,13 +76,66 @@ protected function defineEnvironment(ApplicationContract $app): void 'permission.column_names.team_foreign_key' => 'team_test_id', 'permission.column_names.role_pivot_key' => 'role_test_id', 'permission.column_names.permission_pivot_key' => 'permission_test_id', - 'permission.cache.store' => 'array', - 'permission.models.default_model' => User::class, - 'auth.guards.web' => ['driver' => 'session', 'provider' => 'users'], - 'auth.guards.api' => ['driver' => 'session', 'provider' => 'users'], - 'auth.guards.admin' => ['driver' => 'session', 'provider' => 'admins'], - 'auth.providers.users' => ['driver' => 'eloquent', 'model' => User::class], - 'auth.providers.admins' => ['driver' => 'eloquent', 'model' => Admin::class], + 'permission.cache' => [ + 'expiration_seconds' => 86400, + 'store' => 'array', + 'keys' => [ + 'roles' => 'hypervel.permission.cache.roles', + 'model_roles' => 'hypervel.permission.cache.model.roles', + 'model_permissions' => 'hypervel.permission.cache.model.permissions', + 'model_token' => 'hypervel.permission.cache.model.token', + ], + 'column_names_except' => ['created_at', 'updated_at', 'deleted_at'], + ], + 'permission.models' => [ + 'permission' => \Hypervel\Permission\Models\Permission::class, + 'role' => \Hypervel\Permission\Models\Role::class, + 'team' => null, + 'default_model' => User::class, + ], + 'auth.guards.web' => [ + 'driver' => 'session', + 'provider' => 'users', + 'passwords' => 'users', + 'password_timeout' => null, + 'remember' => null, + ], + 'auth.guards.api' => [ + 'driver' => 'session', + 'provider' => 'users', + 'passwords' => null, + 'password_timeout' => null, + 'remember' => null, + ], + 'auth.guards.admin' => [ + 'driver' => 'session', + 'provider' => 'admins', + 'passwords' => null, + 'password_timeout' => null, + 'remember' => null, + ], + 'auth.providers.users' => [ + 'driver' => 'eloquent', + 'model' => User::class, + 'cache' => [ + 'enabled' => false, + 'store' => null, + 'ttl' => 300, + 'prefix' => 'auth_users', + 'tags' => null, + ], + ], + 'auth.providers.admins' => [ + 'driver' => 'eloquent', + 'model' => Admin::class, + 'cache' => [ + 'enabled' => false, + 'store' => null, + 'ttl' => 300, + 'prefix' => 'auth_users', + 'tags' => null, + ], + ], 'view.paths' => [__DIR__ . '/Fixtures/views'], 'cache.default' => 'array', 'cache.stores.array' => ['driver' => 'array'], diff --git a/tests/Permission/Traits/HasAssignedModelsTest.php b/tests/Permission/Traits/HasAssignedModelsTest.php index 7885e7250..07c29c29c 100644 --- a/tests/Permission/Traits/HasAssignedModelsTest.php +++ b/tests/Permission/Traits/HasAssignedModelsTest.php @@ -300,6 +300,17 @@ public function testItUsesConfigDefaultModelWhenResolvingIds(): void $this->assertTrue($user1->fresh()->hasRole($this->testUserRole)); } + public function testNullDefaultModelUsesTheAuthenticatedGuardModelWhenResolvingIds(): void + { + config()->set('permission.models.default_model', null); + + $user = User::create(['email' => 'user@test.com']); + + $this->testUserRole->syncModels([$user->getKey()]); + + $this->assertTrue($user->fresh()->hasRole($this->testUserRole)); + } + public function testUnsavedRoleReverseAssignmentsAreQueryFreeFluentNoOps(): void { $user = User::create(['email' => 'user@test.com']); diff --git a/tests/Permission/Traits/HasRolesTest.php b/tests/Permission/Traits/HasRolesTest.php index 5ee01ae17..445f196af 100644 --- a/tests/Permission/Traits/HasRolesTest.php +++ b/tests/Permission/Traits/HasRolesTest.php @@ -697,7 +697,13 @@ public function testItCanScopeAgainstASpecificGuard(): void public function testItCanScopeAgainstAZeroNamedGuard(): void { - config()->set('auth.guards.0', ['driver' => 'session', 'provider' => 'users']); + config()->set('auth.guards.0', [ + 'driver' => 'session', + 'provider' => 'users', + 'passwords' => null, + 'password_timeout' => null, + 'remember' => null, + ]); $user = User::create(['email' => 'zero-guard@test.com']); $role = app(Role::class)->create(['name' => 'zeroGuardRole', 'guard_name' => '0']); @@ -710,7 +716,13 @@ public function testItCanScopeAgainstAZeroNamedGuard(): void public function testRoleChecksHonorAZeroNamedGuard(): void { - config()->set('auth.guards.0', ['driver' => 'session', 'provider' => 'users']); + config()->set('auth.guards.0', [ + 'driver' => 'session', + 'provider' => 'users', + 'passwords' => null, + 'password_timeout' => null, + 'remember' => null, + ]); $user = User::create(['email' => 'zero-role-checks@test.com']); $zeroRole = app(Role::class)->create(['name' => 'zeroGuardRole', 'guard_name' => '0']); From 7eb27cdbe3d4a25352f5db1ba85b75e06bde8abd Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:42:37 +0000 Subject: [PATCH 021/109] reverb: enforce complete configured application records Make config-provider applications and enabled rate-limiting and webhook blocks consume their declared members directly while preserving the public Application constructor's whole-feature off states. Normalize env-backed enablement and request-size values, keep custom-provider truthiness at the constructor boundary, and make null or blank webhook URLs explicitly disabled. Rebuild unit and server fixtures from the complete shipped record, cover silent rate-limit disablement and webhook member failures, and document complete application, batching, filter, and nullable option semantics. --- src/docs/reverb.md | 63 +++++-- src/reverb/config/reverb.php | 13 +- src/reverb/src/Application.php | 16 +- src/reverb/src/ConfigApplicationProvider.php | 12 +- .../src/Protocols/Pusher/Channels/Channel.php | 11 +- .../InteractsWithPresenceChannels.php | 4 +- .../Pusher/Http/Controllers/Controller.php | 2 +- src/reverb/src/Protocols/Pusher/Server.php | 2 +- src/reverb/src/ReverbServiceProvider.php | 6 +- .../src/Webhooks/HttpWebhookDispatcher.php | 18 +- .../Webhooks/Jobs/FlushWebhookBatchJob.php | 12 +- tests/Integration/Reverb/server.php | 29 +++- tests/Reverb/ApplicationProviderTest.php | 42 +++-- tests/Reverb/ConfigFileTest.php | 66 +++++++ tests/Reverb/EventDispatcherTest.php | 4 +- .../Protocols/Pusher/Channels/ChannelTest.php | 62 +++---- .../Pusher/Channels/PresenceChannelTest.php | 28 +-- .../Protocols/Pusher/ClientEventTest.php | 12 +- .../Protocols/Pusher/EventHandlerTest.php | 16 +- tests/Reverb/ReverbTestCase.php | 31 ++-- .../Servers/Hypervel/GracefulShutdownTest.php | 27 +-- .../Webhooks/DeferredWebhookManagerTest.php | 9 +- .../Webhooks/HttpWebhookDispatcherTest.php | 161 ++++++++++++------ .../Jobs/FlushWebhookBatchJobTest.php | 19 +-- 24 files changed, 436 insertions(+), 229 deletions(-) create mode 100644 tests/Reverb/ConfigFileTest.php diff --git a/src/docs/reverb.md b/src/docs/reverb.md index 0edf0bdd3..8e29f1c42 100644 --- a/src/docs/reverb.md +++ b/src/docs/reverb.md @@ -118,7 +118,7 @@ For example, you may wish to maintain a single Hypervel application which, via R ### Application Options -Each application may also define client connection options, allowed origins, connection limits, message limits, client-event behavior, and message rate limiting: +Each application may also define client connection options, allowed origins, connection limits, message limits, client-event behavior, message rate limiting, and webhooks: ```php 'apps' => [ @@ -147,11 +147,33 @@ Each application may also define client connection options, allowed origins, con 'decay_seconds' => env('REVERB_APP_RATE_LIMIT_DECAY_SECONDS', 60), 'terminate_on_limit' => env('REVERB_APP_RATE_LIMIT_TERMINATE', false), ], + 'webhooks' => [ + 'url' => env('REVERB_WEBHOOK_URL'), + 'events' => [], + 'headers' => [], + 'filter' => [ + 'channel_name_starts_with' => env('REVERB_WEBHOOK_CHANNEL_PREFIX'), + 'channel_name_ends_with' => env('REVERB_WEBHOOK_CHANNEL_SUFFIX'), + ], + 'subscription_count' => env('REVERB_WEBHOOK_SUBSCRIPTION_COUNT', false), + 'disconnect_smoothing_ms' => env('REVERB_WEBHOOK_DISCONNECT_SMOOTHING_MS', 3000), + 'timeout' => env('REVERB_WEBHOOK_TIMEOUT', 5), + 'retries' => env('REVERB_WEBHOOK_RETRIES', 3), + 'retry_delay' => env('REVERB_WEBHOOK_RETRY_DELAY', 1), + 'batching' => [ + 'enabled' => env('REVERB_WEBHOOK_BATCHING_ENABLED', false), + 'max_events' => env('REVERB_WEBHOOK_BATCHING_MAX_EVENTS', 50), + 'max_delay_ms' => env('REVERB_WEBHOOK_BATCHING_MAX_DELAY_MS', 250), + 'max_payload_bytes' => env('REVERB_WEBHOOK_BATCHING_MAX_PAYLOAD_BYTES', 262144), + ], + ], ], ], ], ``` +When using the `config` application provider, each application entry is a complete record. A null `max_connections` value allows the application to accept an unlimited number of connections. + The `accept_client_events_from` option controls which connections may send client events. The default value, `members`, allows client events from connections subscribed to the target private or presence channel. You may use `all` to allow client events from any connection, or any other value to disable client events. The `max_message_size` option limits the size of each WebSocket message sent by a connected client. Reverb also has a server-level `max_request_size` option that limits HTTP API request bodies sent to the Reverb port: @@ -344,11 +366,23 @@ To enable webhooks, configure a webhook URL and the events you would like to rec 'subscription_count' => env('REVERB_WEBHOOK_SUBSCRIPTION_COUNT', false), 'disconnect_smoothing_ms' => env('REVERB_WEBHOOK_DISCONNECT_SMOOTHING_MS', 3000), + 'timeout' => env('REVERB_WEBHOOK_TIMEOUT', 5), + 'retries' => env('REVERB_WEBHOOK_RETRIES', 3), + 'retry_delay' => env('REVERB_WEBHOOK_RETRY_DELAY', 1), + + 'batching' => [ + 'enabled' => env('REVERB_WEBHOOK_BATCHING_ENABLED', false), + 'max_events' => env('REVERB_WEBHOOK_BATCHING_MAX_EVENTS', 50), + 'max_delay_ms' => env('REVERB_WEBHOOK_BATCHING_MAX_DELAY_MS', 250), + 'max_payload_bytes' => env('REVERB_WEBHOOK_BATCHING_MAX_PAYLOAD_BYTES', 262144), + ], ], ``` The `events` array acts as an allowlist. If the array is empty, Reverb will send all supported webhook events except `subscription_count`. The `subscription_count` event is controlled by its own configuration option and does not need to be listed in the `events` array. The `disconnect_smoothing_ms` option delays `channel_vacated` and `member_removed` webhooks after a disconnect so brief reconnects do not produce unnecessary remove / add webhook pairs. +A null or empty `url` disables webhooks. A null `channel_name_starts_with` or `channel_name_ends_with` value disables filtering on that side of the channel name. + ### Delivery and Signing @@ -365,18 +399,14 @@ Custom headers may be configured, but `X-Pusher-Key`, `X-Pusher-Signature`, and ### Batching -For production workloads, you may enable webhook batching to combine many events into fewer HTTP requests: +For production workloads, you may enable webhook batching to combine many events into fewer HTTP requests. The following members belong within the application's existing `webhooks` array: ```php -'webhooks' => [ - 'url' => env('REVERB_WEBHOOK_URL'), - - 'batching' => [ - 'enabled' => env('REVERB_WEBHOOK_BATCHING_ENABLED', false), - 'max_events' => env('REVERB_WEBHOOK_BATCHING_MAX_EVENTS', 50), - 'max_delay_ms' => env('REVERB_WEBHOOK_BATCHING_MAX_DELAY_MS', 250), - 'max_payload_bytes' => env('REVERB_WEBHOOK_BATCHING_MAX_PAYLOAD_BYTES', 262144), - ], +'batching' => [ + 'enabled' => env('REVERB_WEBHOOK_BATCHING_ENABLED', false), + 'max_events' => env('REVERB_WEBHOOK_BATCHING_MAX_EVENTS', 50), + 'max_delay_ms' => env('REVERB_WEBHOOK_BATCHING_MAX_DELAY_MS', 250), + 'max_payload_bytes' => env('REVERB_WEBHOOK_BATCHING_MAX_PAYLOAD_BYTES', 262144), ], ``` @@ -385,15 +415,12 @@ When batching is enabled, Reverb buffers events in Redis and schedules flush job ### Failure Handling -You may configure webhook delivery timeouts and retry behavior using the `timeout`, `retries`, and `retry_delay` options: +You may configure webhook delivery timeouts and retry behavior using the following members within the application's existing `webhooks` array: ```php -'webhooks' => [ - 'url' => env('REVERB_WEBHOOK_URL'), - 'timeout' => env('REVERB_WEBHOOK_TIMEOUT', 5), - 'retries' => env('REVERB_WEBHOOK_RETRIES', 3), - 'retry_delay' => env('REVERB_WEBHOOK_RETRY_DELAY', 1), -], +'timeout' => env('REVERB_WEBHOOK_TIMEOUT', 5), +'retries' => env('REVERB_WEBHOOK_RETRIES', 3), +'retry_delay' => env('REVERB_WEBHOOK_RETRY_DELAY', 1), ``` If a webhook delivery exhausts all retry attempts, Reverb dispatches the `Hypervel\Reverb\Webhooks\Events\WebhookFailed` event. diff --git a/src/reverb/config/reverb.php b/src/reverb/config/reverb.php index d990d52b3..74f8a30e6 100644 --- a/src/reverb/config/reverb.php +++ b/src/reverb/config/reverb.php @@ -15,7 +15,7 @@ | */ - 'enabled' => env('REVERB_ENABLED', true), + 'enabled' => (bool) env('REVERB_ENABLED', true), /* |-------------------------------------------------------------------------- @@ -49,7 +49,7 @@ 'options' => [ 'tls' => [], ], - 'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000), + 'max_request_size' => (int) env('REVERB_MAX_REQUEST_SIZE', 10_000), /* |-------------------------------------------------------------- | Multi-Instance Scaling via Redis @@ -74,7 +74,7 @@ */ 'scaling' => [ - 'enabled' => env('REVERB_SCALING_ENABLED', false), + 'enabled' => (bool) env('REVERB_SCALING_ENABLED', false), 'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'), 'connection' => env('REVERB_SCALING_CONNECTION', 'reverb'), ], @@ -121,6 +121,7 @@ | Here you may define how Reverb applications are managed. If you choose | to use the "config" provider, you may define an array of apps which | your server will support, including their connection credentials. + | A null max_connections value allows unlimited connections for an app. | */ @@ -145,7 +146,7 @@ 'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000), 'accept_client_events_from' => env('REVERB_APP_ACCEPT_CLIENT_EVENTS_FROM', 'members'), 'rate_limiting' => [ - 'enabled' => env('REVERB_APP_RATE_LIMITING_ENABLED', false), + 'enabled' => (bool) env('REVERB_APP_RATE_LIMITING_ENABLED', false), 'max_attempts' => env('REVERB_APP_RATE_LIMIT_MAX_ATTEMPTS', 60), 'decay_seconds' => env('REVERB_APP_RATE_LIMIT_DECAY_SECONDS', 60), 'terminate_on_limit' => env('REVERB_APP_RATE_LIMIT_TERMINATE', false), @@ -163,6 +164,8 @@ | on the "reverb-webhooks" Redis queue. Payloads are signed | with HMAC-SHA256 using the app secret and include an | X-Pusher-Key header for app identification. + | A null or empty URL disables webhooks. A null prefix filter + | matches any prefix, while a null suffix matches any suffix. | | Enable batching for production workloads — it consolidates | many events into fewer HTTP requests, significantly reducing @@ -210,7 +213,7 @@ 'retry_delay' => env('REVERB_WEBHOOK_RETRY_DELAY', 1), 'batching' => [ - 'enabled' => env('REVERB_WEBHOOK_BATCHING_ENABLED', false), + 'enabled' => (bool) env('REVERB_WEBHOOK_BATCHING_ENABLED', false), 'max_events' => env('REVERB_WEBHOOK_BATCHING_MAX_EVENTS', 50), 'max_delay_ms' => env('REVERB_WEBHOOK_BATCHING_MAX_DELAY_MS', 250), 'max_payload_bytes' => env('REVERB_WEBHOOK_BATCHING_MAX_PAYLOAD_BYTES', 262144), diff --git a/src/reverb/src/Application.php b/src/reverb/src/Application.php index de39b3fe2..d2570d62f 100644 --- a/src/reverb/src/Application.php +++ b/src/reverb/src/Application.php @@ -8,6 +8,9 @@ class Application { /** * Create a new application instance. + * + * Rate limiting must be null or a complete record. Webhooks must be an + * empty array or a complete record. */ public function __construct( protected string $id, @@ -120,7 +123,7 @@ public function rateLimiting(): ?array */ public function usesRateLimiting(): bool { - return ($this->rateLimiting['enabled'] ?? false) === true; + return $this->rateLimiting !== null && (bool) $this->rateLimiting['enabled']; } /** @@ -144,12 +147,21 @@ public function webhooks(): array */ public function hasWebhooks(): bool { - return ! empty($this->webhooks['url']); + if ($this->webhooks === []) { + return false; + } + + $url = $this->webhooks['url']; + + return $url !== null && $url !== ''; } /** * Convert the application to an array. * + * This is the Pusher client configuration shape, not the complete + * application constructor or configuration record. + * * @return array */ public function toArray(): array diff --git a/src/reverb/src/ConfigApplicationProvider.php b/src/reverb/src/ConfigApplicationProvider.php index 41e9c421b..4bcd4c6c1 100644 --- a/src/reverb/src/ConfigApplicationProvider.php +++ b/src/reverb/src/ConfigApplicationProvider.php @@ -73,14 +73,14 @@ protected function buildApplication(array $app): Application $app['key'], $app['secret'], (int) $app['ping_interval'], - (int) ($app['activity_timeout'] ?? 30), + (int) $app['activity_timeout'], $app['allowed_origins'], (int) $app['max_message_size'], - isset($app['max_connections']) ? (int) $app['max_connections'] : null, - $app['accept_client_events_from'] ?? 'members', - $app['rate_limiting'] ?? null, - $app['options'] ?? [], - $app['webhooks'] ?? [], + $app['max_connections'] === null ? null : (int) $app['max_connections'], + $app['accept_client_events_from'], + $app['rate_limiting'], + $app['options'], + $app['webhooks'], ); } } diff --git a/src/reverb/src/Protocols/Pusher/Channels/Channel.php b/src/reverb/src/Protocols/Pusher/Channels/Channel.php index ed723b8e1..b33863769 100644 --- a/src/reverb/src/Protocols/Pusher/Channels/Channel.php +++ b/src/reverb/src/Protocols/Pusher/Channels/Channel.php @@ -161,7 +161,7 @@ protected function handleChannelOccupied(Connection $connection, SharedState $sh // the smoothing window), suppress the channel_occupied webhook — the // channel was never truly vacated from the consumer's perspective. if ($app->hasWebhooks()) { - $smoothingMs = (int) ($app->webhooks()['disconnect_smoothing_ms'] ?? 3000); + $smoothingMs = (int) $app->webhooks()['disconnect_smoothing_ms']; $cancelledLocally = app(DeferredWebhookManager::class)->cancelChannelVacated( $app->id(), @@ -269,7 +269,7 @@ protected function handleChannelVacated(Connection $connection): void return; } - $delayMs = (int) ($app->webhooks()['disconnect_smoothing_ms'] ?? 3000); + $delayMs = (int) $app->webhooks()['disconnect_smoothing_ms']; $manager = app(DeferredWebhookManager::class); if ($delayMs > 0 && $connection->isDisconnecting() && ! $manager->isDraining()) { @@ -297,9 +297,14 @@ protected function dispatchSubscriptionCountWebhook( } $app = $connection->app(); + + if (! $app->hasWebhooks()) { + return; + } + $webhooks = $app->webhooks(); - if (! $app->hasWebhooks() || ! ($webhooks['subscription_count'] ?? false)) { + if (! $webhooks['subscription_count']) { return; } diff --git a/src/reverb/src/Protocols/Pusher/Channels/Concerns/InteractsWithPresenceChannels.php b/src/reverb/src/Protocols/Pusher/Channels/Concerns/InteractsWithPresenceChannels.php index 8a8177564..0cad58137 100644 --- a/src/reverb/src/Protocols/Pusher/Channels/Concerns/InteractsWithPresenceChannels.php +++ b/src/reverb/src/Protocols/Pusher/Channels/Concerns/InteractsWithPresenceChannels.php @@ -59,7 +59,7 @@ public function subscribe(Connection $connection, ?string $auth = null, ?string $app = $connection->app(); if ($presenceUserId !== '' && $app->hasWebhooks()) { - $smoothingMs = (int) ($app->webhooks()['disconnect_smoothing_ms'] ?? 3000); + $smoothingMs = (int) $app->webhooks()['disconnect_smoothing_ms']; $cancelledLocally = app(DeferredWebhookManager::class)->cancelMemberRemoved( $app->id(), @@ -120,7 +120,7 @@ public function unsubscribe(Connection $connection, ?string $userId = null): voi $app = $connection->app(); if ($app->hasWebhooks()) { - $delayMs = (int) ($app->webhooks()['disconnect_smoothing_ms'] ?? 3000); + $delayMs = (int) $app->webhooks()['disconnect_smoothing_ms']; $manager = app(DeferredWebhookManager::class); if ($delayMs > 0 && $connection->isDisconnecting() && ! $manager->isDraining()) { diff --git a/src/reverb/src/Protocols/Pusher/Http/Controllers/Controller.php b/src/reverb/src/Protocols/Pusher/Http/Controllers/Controller.php index 6f7b0a634..8d5510d7c 100644 --- a/src/reverb/src/Protocols/Pusher/Http/Controllers/Controller.php +++ b/src/reverb/src/Protocols/Pusher/Http/Controllers/Controller.php @@ -72,7 +72,7 @@ protected function verifySignature(Request $request, Application $application, s $path = $request->getPathInfo(); - if ($prefix = config('reverb.servers.reverb.path')) { + if ($prefix = config()->string('reverb.servers.reverb.path')) { $path = '/' . ltrim(Str::after($path, rtrim($prefix, '/')), '/'); } diff --git a/src/reverb/src/Protocols/Pusher/Server.php b/src/reverb/src/Protocols/Pusher/Server.php index 4d3e214dc..577804d13 100644 --- a/src/reverb/src/Protocols/Pusher/Server.php +++ b/src/reverb/src/Protocols/Pusher/Server.php @@ -138,7 +138,7 @@ public function message(Connection $from, string $message): void } } catch (Throwable $e) { $terminateOnLimit = $e instanceof RateLimitExceeded - && ($from->app()->rateLimiting()['terminate_on_limit'] ?? false); + && $from->app()->rateLimiting()['terminate_on_limit']; try { $this->error($from, $e); diff --git a/src/reverb/src/ReverbServiceProvider.php b/src/reverb/src/ReverbServiceProvider.php index 8db946171..a6e3cea83 100644 --- a/src/reverb/src/ReverbServiceProvider.php +++ b/src/reverb/src/ReverbServiceProvider.php @@ -147,7 +147,7 @@ protected function registerWebSocketServer(): void $config = $this->app->make('config'); $reverbServer = $config->array('reverb.servers.reverb'); - $servers = $config->array('server.servers', []); + $servers = $config->array('server.servers'); /** @var array $tlsConfiguration */ $tlsConfiguration = $reverbServer['options']['tls']; $tls = TlsOptions::fromArray($tlsConfiguration); @@ -488,7 +488,7 @@ protected function flushWebhookBuffers(): void $webhooks = $app->webhooks(); - if (! ($webhooks['batching']['enabled'] ?? false)) { + if (! $webhooks['batching']['enabled']) { continue; } @@ -566,7 +566,7 @@ protected function recoverStaleWebhookBatches(): void $webhooks = $app->webhooks(); - if (! ($webhooks['batching']['enabled'] ?? false)) { + if (! $webhooks['batching']['enabled']) { continue; } diff --git a/src/reverb/src/Webhooks/HttpWebhookDispatcher.php b/src/reverb/src/Webhooks/HttpWebhookDispatcher.php index c68c3ac92..c100b3a46 100644 --- a/src/reverb/src/Webhooks/HttpWebhookDispatcher.php +++ b/src/reverb/src/Webhooks/HttpWebhookDispatcher.php @@ -25,7 +25,7 @@ public function dispatch(Application $application, string $event, array $data = } $webhooks = $application->webhooks(); - $allowedEvents = $webhooks['events'] ?? []; + $allowedEvents = $webhooks['events']; // subscription_count has its own opt-in (webhooks.subscription_count boolean) // and bypasses the events allowlist — it's already gated by the caller. @@ -33,14 +33,14 @@ public function dispatch(Application $application, string $event, array $data = return; } - $channelPrefix = $webhooks['filter']['channel_name_starts_with'] ?? null; + $channelPrefix = $webhooks['filter']['channel_name_starts_with']; if ($channelPrefix !== null && isset($data['channel'])) { if (! str_starts_with($data['channel'], $channelPrefix)) { return; } } - $channelSuffix = $webhooks['filter']['channel_name_ends_with'] ?? null; + $channelSuffix = $webhooks['filter']['channel_name_ends_with']; if ($channelSuffix !== null && isset($data['channel'])) { if (! str_ends_with($data['channel'], $channelSuffix)) { return; @@ -49,7 +49,7 @@ public function dispatch(Application $application, string $event, array $data = $eventData = $this->buildEventData($application, $event, $data, $connection); - $batchingEnabled = (bool) ($webhooks['batching']['enabled'] ?? false); + $batchingEnabled = (bool) $webhooks['batching']['enabled']; if ($batchingEnabled) { $buffer = app(WebhookBatchBuffer::class); @@ -60,7 +60,7 @@ public function dispatch(Application $application, string $event, array $data = FlushWebhookBatchJob::dispatch($application->id(), $webhooks) ->onQueue('reverb-webhook-flush') ->delay(now()->addMilliseconds( - (int) ($webhooks['batching']['max_delay_ms'] ?? 250) + (int) $webhooks['batching']['max_delay_ms'] )); } catch (Throwable $exception) { try { @@ -85,10 +85,10 @@ public function dispatch(Application $application, string $event, array $data = $webhooks['url'], $application->key(), $application->secret(), - (int) ($webhooks['retries'] ?? 3), - (int) ($webhooks['retry_delay'] ?? 1), - (int) ($webhooks['timeout'] ?? 5), - $webhooks['headers'] ?? [], + (int) $webhooks['retries'], + (int) $webhooks['retry_delay'], + (int) $webhooks['timeout'], + $webhooks['headers'], ); } } diff --git a/src/reverb/src/Webhooks/Jobs/FlushWebhookBatchJob.php b/src/reverb/src/Webhooks/Jobs/FlushWebhookBatchJob.php index 781511a5b..62e862e81 100644 --- a/src/reverb/src/Webhooks/Jobs/FlushWebhookBatchJob.php +++ b/src/reverb/src/Webhooks/Jobs/FlushWebhookBatchJob.php @@ -41,8 +41,8 @@ public function handle(WebhookBatchBuffer $buffer): void $buffer->clearFlushLock($this->appId); $config = $this->webhookConfig; - $maxEvents = (int) ($config['batching']['max_events'] ?? 50); - $maxBytes = (int) ($config['batching']['max_payload_bytes'] ?? 262144); + $maxEvents = (int) $config['batching']['max_events']; + $maxBytes = (int) $config['batching']['max_payload_bytes']; // Claim events atomically — moves them from buffer to processing hash. // If claim returns empty, either the buffer is empty or another flush @@ -68,10 +68,10 @@ public function handle(WebhookBatchBuffer $buffer): void $config['url'], $application->key(), $application->secret(), - (int) ($config['retries'] ?? 3), - (int) ($config['retry_delay'] ?? 1), - (int) ($config['timeout'] ?? 5), - $config['headers'] ?? [], + (int) $config['retries'], + (int) $config['retry_delay'], + (int) $config['timeout'], + $config['headers'], ); // Acknowledge — delete the processing key now that delivery is queued diff --git a/tests/Integration/Reverb/server.php b/tests/Integration/Reverb/server.php index 5f79fad5f..89499fe06 100644 --- a/tests/Integration/Reverb/server.php +++ b/tests/Integration/Reverb/server.php @@ -88,24 +88,31 @@ // Boot a fully bootstrapped Hypervel app with Reverb enabled. $app = TestbenchApplication::create( resolvingCallback: function ($app) use ($workerNum) { + $config = $app->make('config'); + // Clear the default HTTP server entry — the test server only needs the // Reverb WebSocket server. Must happen before the provider registers so // registerWebSocketServer() appends to an empty array. - $app->make('config')->set('server.servers', []); + $config->set('server.servers', []); // Register Reverb provider (register + boot fires immediately since app is booted). // registerWebSocketServer() appends the Reverb server entry using the port // from REVERB_SERVER_PORT (set above via env vars → config). $app->register(ReverbServiceProvider::class); + $baseApplication = $config->array('reverb.apps.apps.0'); + $disabledRateLimiting = array_replace($baseApplication['rate_limiting'], ['enabled' => false]); + $disabledWebhooks = array_replace($baseApplication['webhooks'], ['url' => null]); + // Webhook inspection only works with worker_num=1 (no fork). // Queue::fake() creates an in-memory fake that doesn't survive forking. if ($workerNum === 1) { - $app->make('config')->set('reverb.apps.apps.0.webhooks', [ + $config->set('reverb.apps.apps.0.webhooks', array_replace($baseApplication['webhooks'], [ 'url' => 'https://example.com/webhook', 'events' => ['channel_occupied', 'channel_vacated', 'member_added', 'member_removed', 'client_event'], 'disconnect_smoothing_ms' => 0, - ]); + 'batching' => array_replace($baseApplication['webhooks']['batching'], ['enabled' => false]), + ])); Queue::fake([WebhookDeliveryJob::class]); } @@ -113,7 +120,7 @@ // Add additional test apps (env vars only support one app). // Boot-time configuration mutations are tracked automatically and survive // the worker-start configuration rebuild. - $app->make('config')->set('reverb.apps.apps.1', [ + $config->set('reverb.apps.apps.1', array_replace($baseApplication, [ 'key' => 'reverb-key-2', 'secret' => 'reverb-secret-2', 'app_id' => '654321', @@ -122,9 +129,12 @@ 'activity_timeout' => 30, 'max_message_size' => 1_000_000, 'max_connections' => 1, - ]); + 'accept_client_events_from' => 'members', + 'rate_limiting' => $disabledRateLimiting, + 'webhooks' => $disabledWebhooks, + ])); - $app->make('config')->set('reverb.apps.apps.2', [ + $config->set('reverb.apps.apps.2', array_replace($baseApplication, [ 'key' => 'reverb-key-3', 'secret' => 'reverb-secret-3', 'app_id' => '987654', @@ -132,7 +142,11 @@ 'ping_interval' => 10, 'activity_timeout' => 30, 'max_message_size' => 1, - ]); + 'max_connections' => null, + 'accept_client_events_from' => 'members', + 'rate_limiting' => $disabledRateLimiting, + 'webhooks' => $disabledWebhooks, + ])); // Wrap the ApplicationProvider with a dynamic resolver for parallel test // isolation. Each paratest worker derives unique app credentials from @@ -240,7 +254,6 @@ }); // Override Swoole settings for test determinism. - $config = $app->make('config'); if ($workerNum > 1) { $config->set('server.mode', SWOOLE_PROCESS); } else { diff --git a/tests/Reverb/ApplicationProviderTest.php b/tests/Reverb/ApplicationProviderTest.php index 302c2b70c..6c6179ffe 100644 --- a/tests/Reverb/ApplicationProviderTest.php +++ b/tests/Reverb/ApplicationProviderTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Reverb; +use ErrorException; use Hypervel\Reverb\Application; use Hypervel\Reverb\ApplicationManager; use Hypervel\Reverb\ConfigApplicationProvider; @@ -43,8 +44,9 @@ public function testRetrievesApplicationsFromCustomProvider(): void public function testHandlesStringTypedConfigValuesFromEnv(): void { // env() returns strings — ConfigApplicationProvider must cast to correct types + $application = config()->array('reverb.apps.apps.0'); $provider = new ConfigApplicationProvider(collect([ - [ + array_replace($application, [ 'app_id' => '123456', 'key' => 'reverb-key', 'secret' => 'reverb-secret', @@ -54,7 +56,8 @@ public function testHandlesStringTypedConfigValuesFromEnv(): void 'max_message_size' => '10000', 'max_connections' => '100', 'accept_client_events_from' => 'members', - ], + 'rate_limiting' => array_replace($application['rate_limiting'], ['enabled' => '1']), + ]), ])); $app = $provider->findByKey('reverb-key'); @@ -65,24 +68,31 @@ public function testHandlesStringTypedConfigValuesFromEnv(): void $this->assertSame(30, $app->activityTimeout()); $this->assertSame(10000, $app->maxMessageSize()); $this->assertSame(100, $app->maxConnections()); + $this->assertTrue($app->usesRateLimiting()); } - public function testDefaultsToMembersWhenAcceptClientEventsFromMissing(): void + public function testMissingAcceptClientEventsFromFailsLoudly(): void { - $provider = new ConfigApplicationProvider(collect([ - [ - 'app_id' => '123456', - 'key' => 'reverb-key', - 'secret' => 'reverb-secret', - 'ping_interval' => 60, - 'allowed_origins' => ['*'], - 'max_message_size' => 10_000, - // accept_client_events_from intentionally omitted - ], - ])); + $application = config()->array('reverb.apps.apps.0'); + unset($application['accept_client_events_from']); - $app = $provider->findByKey('reverb-key'); + $provider = new ConfigApplicationProvider(collect([$application])); + + $this->expectException(ErrorException::class); + $this->expectExceptionMessage('Undefined array key "accept_client_events_from"'); + + $provider->findByKey('reverb-key'); + } + + public function testNullAndBlankWebhookUrlsDisableWebhooks(): void + { + foreach ([null, ''] as $url) { + $application = config()->array('reverb.apps.apps.0'); + $application['webhooks']['url'] = $url; + + $provider = new ConfigApplicationProvider(collect([$application])); - $this->assertSame('members', $app->acceptClientEventsFrom()); + $this->assertFalse($provider->findByKey('reverb-key')->hasWebhooks()); + } } } diff --git a/tests/Reverb/ConfigFileTest.php b/tests/Reverb/ConfigFileTest.php new file mode 100644 index 000000000..df1e1a344 --- /dev/null +++ b/tests/Reverb/ConfigFileTest.php @@ -0,0 +1,66 @@ + '20000', + 'REVERB_SCALING_ENABLED' => '1', + 'REVERB_APP_RATE_LIMITING_ENABLED' => '1', + 'REVERB_WEBHOOK_BATCHING_ENABLED' => '1', + ]; + $originalValues = []; + + foreach ($environment as $key => $value) { + $originalValues[$key] = [ + 'putenv' => getenv($key), + 'server_exists' => array_key_exists($key, $_SERVER), + 'server' => $_SERVER[$key] ?? null, + 'env_exists' => array_key_exists($key, $_ENV), + 'env' => $_ENV[$key] ?? null, + ]; + + unset($_SERVER[$key], $_ENV[$key]); + putenv("{$key}={$value}"); + } + + try { + Env::flushRepository(); + + $config = require dirname(__DIR__, 2) . '/src/reverb/config/reverb.php'; + + $this->assertSame(20_000, $config['servers']['reverb']['max_request_size']); + $this->assertTrue($config['servers']['reverb']['scaling']['enabled']); + $this->assertTrue($config['apps']['apps'][0]['rate_limiting']['enabled']); + $this->assertTrue($config['apps']['apps'][0]['webhooks']['batching']['enabled']); + } finally { + foreach ($originalValues as $key => $values) { + $values['putenv'] === false + ? putenv($key) + : putenv("{$key}={$values['putenv']}"); + + if ($values['server_exists']) { + $_SERVER[$key] = $values['server']; + } else { + unset($_SERVER[$key]); + } + + if ($values['env_exists']) { + $_ENV[$key] = $values['env']; + } else { + unset($_ENV[$key]); + } + } + + Env::flushRepository(); + } + } +} diff --git a/tests/Reverb/EventDispatcherTest.php b/tests/Reverb/EventDispatcherTest.php index 94a2c74c1..8f7214aff 100644 --- a/tests/Reverb/EventDispatcherTest.php +++ b/tests/Reverb/EventDispatcherTest.php @@ -206,11 +206,11 @@ public function testCacheMissLockClearsOnVacateAndFiresOnRecreation(): void { Queue::fake(); - config()->set('reverb.apps.apps.0.webhooks', [ + config()->set('reverb.apps.apps.0.webhooks', array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => ['cache_miss'], 'disconnect_smoothing_ms' => 0, - ]); + ])); $app = app(ApplicationProvider::class)->findByKey('reverb-key'); $channels = app(ChannelManager::class)->for($app); diff --git a/tests/Reverb/Protocols/Pusher/Channels/ChannelTest.php b/tests/Reverb/Protocols/Pusher/Channels/ChannelTest.php index e7b81cc55..6c36dbfe6 100644 --- a/tests/Reverb/Protocols/Pusher/Channels/ChannelTest.php +++ b/tests/Reverb/Protocols/Pusher/Channels/ChannelTest.php @@ -282,11 +282,11 @@ public function testSubscribeFiresSubscriptionCountWebhook(): void { Queue::fake(); - config()->set('reverb.apps.apps.0.webhooks', [ + config()->set('reverb.apps.apps.0.webhooks', array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => [], 'subscription_count' => true, - ]); + ])); $this->subscribeConnection('test-channel'); @@ -303,12 +303,12 @@ public function testUnsubscribeFiresSubscriptionCountWebhook(): void { Queue::fake(); - config()->set('reverb.apps.apps.0.webhooks', [ + config()->set('reverb.apps.apps.0.webhooks', array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => [], 'subscription_count' => true, 'disconnect_smoothing_ms' => 0, - ]); + ])); $connection1 = $this->subscribeConnection('test-channel'); $connection2 = $this->subscribeConnection('test-channel'); @@ -330,11 +330,11 @@ public function testSubscriptionCountNotFiredWhenOptInIsFalse(): void { Queue::fake(); - config()->set('reverb.apps.apps.0.webhooks', [ + config()->set('reverb.apps.apps.0.webhooks', array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => [], - // subscription_count not set — defaults to false - ]); + 'subscription_count' => false, + ])); $this->subscribeConnection('test-channel'); @@ -347,11 +347,11 @@ public function testSubscriptionCountNotFiredForPresenceChannels(): void { Queue::fake(); - config()->set('reverb.apps.apps.0.webhooks', [ + config()->set('reverb.apps.apps.0.webhooks', array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => [], 'subscription_count' => true, - ]); + ])); $this->subscribeConnection('presence-test', ['user_id' => '1', 'user_info' => ['name' => 'Test']]); @@ -364,11 +364,11 @@ public function testSubscriptionCountNotFiredForPresenceCacheChannels(): void { Queue::fake(); - config()->set('reverb.apps.apps.0.webhooks', [ + config()->set('reverb.apps.apps.0.webhooks', array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => [], 'subscription_count' => true, - ]); + ])); $this->subscribeConnection('presence-cache-test', ['user_id' => '1', 'user_info' => ['name' => 'Test']]); @@ -381,11 +381,11 @@ public function testSubscriptionCountFiredForPrivateChannels(): void { Queue::fake(); - config()->set('reverb.apps.apps.0.webhooks', [ + config()->set('reverb.apps.apps.0.webhooks', array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => [], 'subscription_count' => true, - ]); + ])); $this->subscribeConnection('private-test'); @@ -398,11 +398,11 @@ public function testSubscriptionCountFiredForCacheChannels(): void { Queue::fake(); - config()->set('reverb.apps.apps.0.webhooks', [ + config()->set('reverb.apps.apps.0.webhooks', array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => [], 'subscription_count' => true, - ]); + ])); $this->subscribeConnection('cache-test'); @@ -417,11 +417,11 @@ public function testDisconnectDefersChannelVacatedWebhook(): void { Queue::fake(); - config()->set('reverb.apps.apps.0.webhooks', [ + config()->set('reverb.apps.apps.0.webhooks', array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => ['channel_vacated'], 'disconnect_smoothing_ms' => 3000, - ]); + ])); $connection = $this->subscribeConnection('test-channel'); Queue::fake(); @@ -440,11 +440,11 @@ public function testExplicitUnsubscribeFiresChannelVacatedImmediately(): void { Queue::fake(); - config()->set('reverb.apps.apps.0.webhooks', [ + config()->set('reverb.apps.apps.0.webhooks', array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => ['channel_vacated'], 'disconnect_smoothing_ms' => 3000, - ]); + ])); $connection = $this->subscribeConnection('test-channel'); Queue::fake(); @@ -465,11 +465,11 @@ public function testReconnectWithinSmoothingWindowSuppressesChannelOccupied(): v { Queue::fake(); - config()->set('reverb.apps.apps.0.webhooks', [ + config()->set('reverb.apps.apps.0.webhooks', array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => ['channel_occupied', 'channel_vacated'], 'disconnect_smoothing_ms' => 3000, - ]); + ])); // Subscribe, then disconnect (sets smoothing marker + defers vacated) $connection = $this->subscribeConnection('test-channel'); @@ -491,11 +491,11 @@ public function testNormalSubscribeFiresChannelOccupied(): void { Queue::fake(); - config()->set('reverb.apps.apps.0.webhooks', [ + config()->set('reverb.apps.apps.0.webhooks', array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => ['channel_occupied'], 'disconnect_smoothing_ms' => 3000, - ]); + ])); // First subscribe — no prior disconnect, no smoothing marker $this->subscribeConnection('test-channel'); @@ -509,11 +509,11 @@ public function testCrossWorkerSmoothingMarkerSuppressesChannelOccupied(): void { Queue::fake(); - config()->set('reverb.apps.apps.0.webhooks', [ + config()->set('reverb.apps.apps.0.webhooks', array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => ['channel_occupied'], 'disconnect_smoothing_ms' => 3000, - ]); + ])); // Simulate a marker set by another worker's disconnect // (no local timer — cancelChannelVacated will return false) @@ -534,11 +534,11 @@ public function testConsumedMarkerDoesNotSuppressSubsequentLegitimateOccupied(): { Queue::fake(); - config()->set('reverb.apps.apps.0.webhooks', [ + config()->set('reverb.apps.apps.0.webhooks', array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => ['channel_occupied', 'channel_vacated'], 'disconnect_smoothing_ms' => 3000, - ]); + ])); // Set marker (simulating another worker's disconnect) $sharedState = $this->app->make(SharedState::class); @@ -568,11 +568,11 @@ public function testSubscriptionCountThrottledAbove100Subscribers(): void { Queue::fake(); - config()->set('reverb.apps.apps.0.webhooks', [ + config()->set('reverb.apps.apps.0.webhooks', array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => [], 'subscription_count' => true, - ]); + ])); // Mock SharedState to return count >100 and lock already held $sharedState = m::mock(SharedState::class); @@ -603,11 +603,11 @@ public function testSubscriptionCountFiresAbove100WhenLockAcquired(): void { Queue::fake(); - config()->set('reverb.apps.apps.0.webhooks', [ + config()->set('reverb.apps.apps.0.webhooks', array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => [], 'subscription_count' => true, - ]); + ])); // Mock SharedState to return count >100 and lock acquired $sharedState = m::mock(SharedState::class); diff --git a/tests/Reverb/Protocols/Pusher/Channels/PresenceChannelTest.php b/tests/Reverb/Protocols/Pusher/Channels/PresenceChannelTest.php index a7b9b2b4a..f5882d788 100644 --- a/tests/Reverb/Protocols/Pusher/Channels/PresenceChannelTest.php +++ b/tests/Reverb/Protocols/Pusher/Channels/PresenceChannelTest.php @@ -180,11 +180,11 @@ public function testSubscriptionAndUnsubscriptionPreserveZeroUserId(): void { Queue::fake(); - config()->set('reverb.apps.apps.0.webhooks', [ + config()->set('reverb.apps.apps.0.webhooks', array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => ['member_added', 'member_removed'], 'disconnect_smoothing_ms' => 0, - ]); + ])); $channel = $this->channels()->findOrCreate('presence-test-channel'); $data = json_encode(['user_info' => ['name' => 'Zero'], 'user_id' => 0]); @@ -265,11 +265,11 @@ public function testDisconnectDefersMemberRemovedWebhook(): void { Queue::fake(); - config()->set('reverb.apps.apps.0.webhooks', [ + config()->set('reverb.apps.apps.0.webhooks', array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => ['member_removed'], 'disconnect_smoothing_ms' => 3000, - ]); + ])); $channel = $this->channels()->findOrCreate('presence-test-channel'); $data = json_encode(['user_info' => ['name' => 'Test'], 'user_id' => '1']); @@ -299,11 +299,11 @@ public function testExplicitUnsubscribeFiresMemberRemovedImmediately(): void { Queue::fake(); - config()->set('reverb.apps.apps.0.webhooks', [ + config()->set('reverb.apps.apps.0.webhooks', array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => ['member_removed'], 'disconnect_smoothing_ms' => 3000, - ]); + ])); $channel = $this->channels()->findOrCreate('presence-test-channel'); $data = json_encode(['user_info' => ['name' => 'Test'], 'user_id' => '1']); @@ -334,11 +334,11 @@ public function testReconnectWithinSmoothingWindowSuppressesMemberAddedWebhook() { Queue::fake(); - config()->set('reverb.apps.apps.0.webhooks', [ + config()->set('reverb.apps.apps.0.webhooks', array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => ['member_added', 'member_removed'], 'disconnect_smoothing_ms' => 3000, - ]); + ])); $channel = $this->channels()->findOrCreate('presence-test-channel'); $data = json_encode(['user_info' => ['name' => 'Test'], 'user_id' => '1']); @@ -376,11 +376,11 @@ public function testReconnectStillSendsInternalMemberAddedEvent(): void { Queue::fake(); - config()->set('reverb.apps.apps.0.webhooks', [ + config()->set('reverb.apps.apps.0.webhooks', array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => ['member_added', 'member_removed'], 'disconnect_smoothing_ms' => 3000, - ]); + ])); $channel = $this->channels()->findOrCreate('presence-test-channel'); $data = json_encode(['user_info' => ['name' => 'Test'], 'user_id' => '1']); @@ -425,11 +425,11 @@ public function testCrossWorkerSmoothingMarkerSuppressesMemberAdded(): void { Queue::fake(); - config()->set('reverb.apps.apps.0.webhooks', [ + config()->set('reverb.apps.apps.0.webhooks', array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => ['member_added'], 'disconnect_smoothing_ms' => 3000, - ]); + ])); // Simulate a marker set by another worker's disconnect $sharedState = $this->app->make(SharedState::class); @@ -454,11 +454,11 @@ public function testConsumedMemberMarkerDoesNotSuppressSubsequentLegitimateAdd() { Queue::fake(); - config()->set('reverb.apps.apps.0.webhooks', [ + config()->set('reverb.apps.apps.0.webhooks', array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => ['member_added', 'member_removed'], 'disconnect_smoothing_ms' => 3000, - ]); + ])); // Set marker (simulating another worker's disconnect) $sharedState = $this->app->make(SharedState::class); diff --git a/tests/Reverb/Protocols/Pusher/ClientEventTest.php b/tests/Reverb/Protocols/Pusher/ClientEventTest.php index 8032b9574..5036715db 100644 --- a/tests/Reverb/Protocols/Pusher/ClientEventTest.php +++ b/tests/Reverb/Protocols/Pusher/ClientEventTest.php @@ -61,10 +61,10 @@ public function testClientMessagePreservesZeroUserIdInBroadcastAndWebhook(): voi { Queue::fake(); - config()->set('reverb.apps.apps.0.webhooks', [ + config()->set('reverb.apps.apps.0.webhooks', array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => ['client_event'], - ]); + ])); $this->channels()->findOrCreate('presence-test-channel'); @@ -161,10 +161,10 @@ public function testRejectClientEventOnPublicChannelDoesNotProduceWebhook(): voi { Queue::fake(); - config()->set('reverb.apps.apps.0.webhooks', [ + config()->set('reverb.apps.apps.0.webhooks', array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => ['client_event'], - ]); + ])); $this->channels()->findOrCreate('test-channel'); @@ -299,10 +299,10 @@ public function testWebhookIncludesUserIdForPresenceChannelInAllMode(): void Queue::fake(); config()->set('reverb.apps.apps.0.accept_client_events_from', 'all'); - config()->set('reverb.apps.apps.0.webhooks', [ + config()->set('reverb.apps.apps.0.webhooks', array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => ['client_event'], - ]); + ])); $connectionData = ['user_info' => ['name' => 'Taylor'], 'user_id' => '42']; $channelConnection = collect(static::factory(data: $connectionData))->first(); diff --git a/tests/Reverb/Protocols/Pusher/EventHandlerTest.php b/tests/Reverb/Protocols/Pusher/EventHandlerTest.php index c1e4ef0e8..70d5763b2 100644 --- a/tests/Reverb/Protocols/Pusher/EventHandlerTest.php +++ b/tests/Reverb/Protocols/Pusher/EventHandlerTest.php @@ -319,10 +319,10 @@ public function testCacheMissFiresWebhook(): void { Queue::fake(); - config()->set('reverb.apps.apps.0.webhooks', [ + config()->set('reverb.apps.apps.0.webhooks', array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => ['cache_miss'], - ]); + ])); $this->pusher->subscribe($this->connection, 'cache-test-channel'); @@ -338,10 +338,10 @@ public function testCacheHitDoesNotFireWebhook(): void { Queue::fake(); - config()->set('reverb.apps.apps.0.webhooks', [ + config()->set('reverb.apps.apps.0.webhooks', array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => ['cache_miss'], - ]); + ])); // Subscribe first to create the channel, then broadcast to populate cache $this->pusher->subscribe($this->connection, 'cache-test-channel'); @@ -366,10 +366,10 @@ public function testCacheMissWebhookIsDeduplicated(): void { Queue::fake(); - config()->set('reverb.apps.apps.0.webhooks', [ + config()->set('reverb.apps.apps.0.webhooks', array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => ['cache_miss'], - ]); + ])); // Two connections subscribe to the same empty cache channel $this->pusher->subscribe($this->connection, 'cache-test-channel'); @@ -392,10 +392,10 @@ public function testCacheMissWebhookRespectsEventFilter(): void { Queue::fake(); - config()->set('reverb.apps.apps.0.webhooks', [ + config()->set('reverb.apps.apps.0.webhooks', array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => ['channel_occupied'], // cache_miss is not in the list - ]); + ])); $this->pusher->subscribe($this->connection, 'cache-test-channel'); diff --git a/tests/Reverb/ReverbTestCase.php b/tests/Reverb/ReverbTestCase.php index 9322bc3e8..8a3d9d2b8 100644 --- a/tests/Reverb/ReverbTestCase.php +++ b/tests/Reverb/ReverbTestCase.php @@ -44,9 +44,10 @@ protected function getPackageProviders(ApplicationContract $app): array protected function defineEnvironment(ApplicationContract $app): void { $config = $app->make('config'); + $application = $config->array('reverb.apps.apps.0'); $config->set('reverb.apps.apps', [ - [ + array_replace($application, [ 'key' => 'reverb-key', 'secret' => 'reverb-secret', 'app_id' => '123456', @@ -56,29 +57,24 @@ protected function defineEnvironment(ApplicationContract $app): void 'scheme' => 'https', 'useTLS' => true, ], - 'allowed_origins' => ['*'], - 'ping_interval' => 60, - 'activity_timeout' => 30, - 'max_message_size' => 10_000, - 'accept_client_events_from' => 'members', - ], + ]), ]); - $redisConnection = [ + $redisConnection = array_replace($config->array('database.redis.default'), [ + 'url' => null, 'host' => '127.0.0.1', 'port' => 6379, 'database' => 0, - 'pool' => [ + 'pool' => array_replace($config->array('database.redis.default.pool'), [ 'min_connections' => 1, 'max_connections' => 1, 'connect_timeout' => 10.0, 'wait_timeout' => 3.0, 'heartbeat' => -1, 'max_idle_time' => 60.0, - ], - ]; + ]), + ]); - $config->set('database.redis.options', []); $config->set('database.redis.default', $redisConnection); $config->set('database.redis.queue', $redisConnection); $config->set('database.redis.reverb', $redisConnection); @@ -90,6 +86,17 @@ protected function defineEnvironment(ApplicationContract $app): void $app->instance(Server::class, $server); } + /** + * Get the currently configured complete webhook record. + * + * The base environment preserves the shipped record so tests can replace + * individual members without relying on source-level defaults. + */ + protected function webhookConfig(): array + { + return config()->array('reverb.apps.apps.0.webhooks'); + } + /** * Create a defined number of channel connections. * diff --git a/tests/Reverb/Servers/Hypervel/GracefulShutdownTest.php b/tests/Reverb/Servers/Hypervel/GracefulShutdownTest.php index c0c89635a..5cf300f21 100644 --- a/tests/Reverb/Servers/Hypervel/GracefulShutdownTest.php +++ b/tests/Reverb/Servers/Hypervel/GracefulShutdownTest.php @@ -206,11 +206,11 @@ public function testFlushWebhookBuffersSchedulesFlushJob(): void { Queue::fake([FlushWebhookBatchJob::class]); - config()->set('reverb.apps.apps.0.webhooks', [ - 'url' => 'https://example.com/webhook', - 'events' => ['channel_occupied'], - 'batching' => ['enabled' => true], - ]); + $webhooks = $this->webhookConfig(); + $webhooks['url'] = 'https://example.com/webhook'; + $webhooks['events'] = ['channel_occupied']; + $webhooks['batching']['enabled'] = true; + config()->set('reverb.apps.apps.0.webhooks', $webhooks); $buffer = m::mock(WebhookBatchBuffer::class); $buffer->shouldReceive('clearFlushLock')->once(); @@ -240,11 +240,11 @@ public function testFlushWebhookBuffersSkipsWhenBufferEmpty(): void { Queue::fake([FlushWebhookBatchJob::class]); - config()->set('reverb.apps.apps.0.webhooks', [ - 'url' => 'https://example.com/webhook', - 'events' => ['channel_occupied'], - 'batching' => ['enabled' => true], - ]); + $webhooks = $this->webhookConfig(); + $webhooks['url'] = 'https://example.com/webhook'; + $webhooks['events'] = ['channel_occupied']; + $webhooks['batching']['enabled'] = true; + config()->set('reverb.apps.apps.0.webhooks', $webhooks); $buffer = m::mock(WebhookBatchBuffer::class); $buffer->shouldReceive('clearFlushLock')->once(); @@ -294,6 +294,11 @@ public function testShutdownDoesNotLosePreExistingDeferredWebhooks(): void ->andReturn(0); $this->app->instance(SharedState::class, $sharedState); + $webhooks = array_replace($this->webhookConfig(), [ + 'url' => 'https://example.com/webhook', + 'events' => ['channel_vacated'], + ]); + $app = new Application( 'test-app', 'test-key', @@ -302,7 +307,7 @@ public function testShutdownDoesNotLosePreExistingDeferredWebhooks(): void 30, ['*'], 10_000, - webhooks: ['url' => 'https://example.com/webhook', 'events' => ['channel_vacated']], + webhooks: $webhooks, ); $manager = $this->app->make(DeferredWebhookManager::class); diff --git a/tests/Reverb/Webhooks/DeferredWebhookManagerTest.php b/tests/Reverb/Webhooks/DeferredWebhookManagerTest.php index 8b8640e67..8f3180982 100644 --- a/tests/Reverb/Webhooks/DeferredWebhookManagerTest.php +++ b/tests/Reverb/Webhooks/DeferredWebhookManagerTest.php @@ -26,6 +26,10 @@ protected function setUp(): void parent::setUp(); $this->manager = new DeferredWebhookManager; + $webhooks = array_replace($this->webhookConfig(), [ + 'url' => 'https://example.com/webhook', + 'events' => ['channel_vacated', 'member_removed'], + ]); $this->testApp = new Application( 'test-app', 'test-key', @@ -34,10 +38,7 @@ protected function setUp(): void 30, ['*'], 10_000, - webhooks: [ - 'url' => 'https://example.com/webhook', - 'events' => ['channel_vacated', 'member_removed'], - ], + webhooks: $webhooks, ); } diff --git a/tests/Reverb/Webhooks/HttpWebhookDispatcherTest.php b/tests/Reverb/Webhooks/HttpWebhookDispatcherTest.php index a8be18e9b..01a888863 100644 --- a/tests/Reverb/Webhooks/HttpWebhookDispatcherTest.php +++ b/tests/Reverb/Webhooks/HttpWebhookDispatcherTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Reverb\Webhooks; +use ErrorException; use Hypervel\Contracts\Bus\Dispatcher as BusDispatcher; use Hypervel\Reverb\Application; use Hypervel\Reverb\Webhooks\HttpWebhookDispatcher; @@ -21,7 +22,10 @@ public function testDispatchesJobForAllowedEvent(): void { Queue::fake(); - $app = $this->makeApp(webhooks: ['url' => 'https://example.com/webhook', 'events' => ['channel_occupied']]); + $app = $this->makeApp(webhooks: array_replace($this->webhookConfig(), [ + 'url' => 'https://example.com/webhook', + 'events' => ['channel_occupied'], + ])); $dispatcher = new HttpWebhookDispatcher; $dispatcher->dispatch($app, 'channel_occupied', ['channel' => 'test-channel']); @@ -41,7 +45,10 @@ public function testSkipsDispatchForDisallowedEvent(): void { Queue::fake(); - $app = $this->makeApp(webhooks: ['url' => 'https://example.com/webhook', 'events' => ['channel_occupied']]); + $app = $this->makeApp(webhooks: array_replace($this->webhookConfig(), [ + 'url' => 'https://example.com/webhook', + 'events' => ['channel_occupied'], + ])); $dispatcher = new HttpWebhookDispatcher; $dispatcher->dispatch($app, 'member_added', ['channel' => 'test-channel']); @@ -65,7 +72,10 @@ public function testJobUsesRedisConnectionAndDedicatedQueue(): void { Queue::fake(); - $app = $this->makeApp(webhooks: ['url' => 'https://example.com/webhook', 'events' => ['channel_occupied']]); + $app = $this->makeApp(webhooks: array_replace($this->webhookConfig(), [ + 'url' => 'https://example.com/webhook', + 'events' => ['channel_occupied'], + ])); $dispatcher = new HttpWebhookDispatcher; $dispatcher->dispatch($app, 'channel_occupied', ['channel' => 'test-channel']); @@ -80,7 +90,10 @@ public function testDispatchesForAllEventsWhenAllowlistIsEmpty(): void { Queue::fake(); - $app = $this->makeApp(webhooks: ['url' => 'https://example.com/webhook', 'events' => []]); + $app = $this->makeApp(webhooks: array_replace($this->webhookConfig(), [ + 'url' => 'https://example.com/webhook', + 'events' => [], + ])); $dispatcher = new HttpWebhookDispatcher; $dispatcher->dispatch($app, 'member_added', ['channel' => 'presence-chat', 'user_id' => '42']); @@ -95,7 +108,10 @@ public function testClientEventIncludesSocketIdAndStringifiedData(): void { Queue::fake(); - $app = $this->makeApp(webhooks: ['url' => 'https://example.com/webhook', 'events' => ['client_event']]); + $app = $this->makeApp(webhooks: array_replace($this->webhookConfig(), [ + 'url' => 'https://example.com/webhook', + 'events' => ['client_event'], + ])); $connection = new \Hypervel\Tests\Reverb\Fixtures\FakeConnection; $dispatcher = new HttpWebhookDispatcher; @@ -120,11 +136,13 @@ public function testChannelFilterSkipsNonMatchingChannel(): void { Queue::fake(); - $app = $this->makeApp(webhooks: [ + $app = $this->makeApp(webhooks: array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => ['channel_occupied'], - 'filter' => ['channel_name_starts_with' => 'tenant-1-'], - ]); + 'filter' => array_replace($this->webhookConfig()['filter'], [ + 'channel_name_starts_with' => 'tenant-1-', + ]), + ])); $dispatcher = new HttpWebhookDispatcher; $dispatcher->dispatch($app, 'channel_occupied', ['channel' => 'tenant-2-chat']); @@ -136,11 +154,13 @@ public function testChannelFilterAllowsMatchingChannel(): void { Queue::fake(); - $app = $this->makeApp(webhooks: [ + $app = $this->makeApp(webhooks: array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => ['channel_occupied'], - 'filter' => ['channel_name_starts_with' => 'tenant-1-'], - ]); + 'filter' => array_replace($this->webhookConfig()['filter'], [ + 'channel_name_starts_with' => 'tenant-1-', + ]), + ])); $dispatcher = new HttpWebhookDispatcher; $dispatcher->dispatch($app, 'channel_occupied', ['channel' => 'tenant-1-chat']); @@ -152,11 +172,13 @@ public function testChannelFilterDisabledWhenNull(): void { Queue::fake(); - $app = $this->makeApp(webhooks: [ + $app = $this->makeApp(webhooks: array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => ['channel_occupied'], - 'filter' => ['channel_name_starts_with' => null], - ]); + 'filter' => array_replace($this->webhookConfig()['filter'], [ + 'channel_name_starts_with' => null, + ]), + ])); $dispatcher = new HttpWebhookDispatcher; $dispatcher->dispatch($app, 'channel_occupied', ['channel' => 'any-channel']); @@ -168,11 +190,13 @@ public function testChannelEndsWithFilterSkipsNonMatchingChannel(): void { Queue::fake(); - $app = $this->makeApp(webhooks: [ + $app = $this->makeApp(webhooks: array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => ['channel_occupied'], - 'filter' => ['channel_name_ends_with' => '-chat'], - ]); + 'filter' => array_replace($this->webhookConfig()['filter'], [ + 'channel_name_ends_with' => '-chat', + ]), + ])); $dispatcher = new HttpWebhookDispatcher; $dispatcher->dispatch($app, 'channel_occupied', ['channel' => 'tenant-1-notifications']); @@ -184,11 +208,13 @@ public function testChannelEndsWithFilterAllowsMatchingChannel(): void { Queue::fake(); - $app = $this->makeApp(webhooks: [ + $app = $this->makeApp(webhooks: array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => ['channel_occupied'], - 'filter' => ['channel_name_ends_with' => '-chat'], - ]); + 'filter' => array_replace($this->webhookConfig()['filter'], [ + 'channel_name_ends_with' => '-chat', + ]), + ])); $dispatcher = new HttpWebhookDispatcher; $dispatcher->dispatch($app, 'channel_occupied', ['channel' => 'tenant-1-chat']); @@ -200,11 +226,13 @@ public function testChannelEndsWithFilterDisabledWhenNull(): void { Queue::fake(); - $app = $this->makeApp(webhooks: [ + $app = $this->makeApp(webhooks: array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => ['channel_occupied'], - 'filter' => ['channel_name_ends_with' => null], - ]); + 'filter' => array_replace($this->webhookConfig()['filter'], [ + 'channel_name_ends_with' => null, + ]), + ])); $dispatcher = new HttpWebhookDispatcher; $dispatcher->dispatch($app, 'channel_occupied', ['channel' => 'any-channel']); @@ -216,14 +244,14 @@ public function testBothFiltersAppliedAsAnd(): void { Queue::fake(); - $app = $this->makeApp(webhooks: [ + $app = $this->makeApp(webhooks: array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => ['channel_occupied'], 'filter' => [ 'channel_name_starts_with' => 'tenant-1-', 'channel_name_ends_with' => '-chat', ], - ]); + ])); $dispatcher = new HttpWebhookDispatcher; @@ -236,14 +264,14 @@ public function testBothFiltersRejectWhenOnlyPrefixMatches(): void { Queue::fake(); - $app = $this->makeApp(webhooks: [ + $app = $this->makeApp(webhooks: array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => ['channel_occupied'], 'filter' => [ 'channel_name_starts_with' => 'tenant-1-', 'channel_name_ends_with' => '-chat', ], - ]); + ])); $dispatcher = new HttpWebhookDispatcher; $dispatcher->dispatch($app, 'channel_occupied', ['channel' => 'tenant-1-notifications']); @@ -255,14 +283,14 @@ public function testBothFiltersRejectWhenOnlySuffixMatches(): void { Queue::fake(); - $app = $this->makeApp(webhooks: [ + $app = $this->makeApp(webhooks: array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => ['channel_occupied'], 'filter' => [ 'channel_name_starts_with' => 'tenant-1-', 'channel_name_ends_with' => '-chat', ], - ]); + ])); $dispatcher = new HttpWebhookDispatcher; $dispatcher->dispatch($app, 'channel_occupied', ['channel' => 'tenant-2-chat']); @@ -274,11 +302,11 @@ public function testCustomHeadersPassedToJob(): void { Queue::fake(); - $app = $this->makeApp(webhooks: [ + $app = $this->makeApp(webhooks: array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => ['channel_occupied'], 'headers' => ['Authorization' => 'Bearer test-token'], - ]); + ])); $dispatcher = new HttpWebhookDispatcher; $dispatcher->dispatch($app, 'channel_occupied', ['channel' => 'test-channel']); @@ -299,11 +327,14 @@ public function testBatchingAppendsToBufferAndSchedulesFlush(): void ->andReturn(true); $this->app->instance(WebhookBatchBuffer::class, $buffer); - $app = $this->makeApp(webhooks: [ + $app = $this->makeApp(webhooks: array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => ['channel_occupied'], - 'batching' => ['enabled' => true, 'max_delay_ms' => 250], - ]); + 'batching' => array_replace($this->webhookConfig()['batching'], [ + 'enabled' => true, + 'max_delay_ms' => 250, + ]), + ])); $dispatcher = new HttpWebhookDispatcher; $dispatcher->dispatch($app, 'channel_occupied', ['channel' => 'test-channel']); @@ -328,11 +359,13 @@ public function testBatchingDoesNotScheduleFlushWhenLockAlreadyHeld(): void ->andReturn(false); $this->app->instance(WebhookBatchBuffer::class, $buffer); - $app = $this->makeApp(webhooks: [ + $app = $this->makeApp(webhooks: array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => ['channel_occupied'], - 'batching' => ['enabled' => true], - ]); + 'batching' => array_replace($this->webhookConfig()['batching'], [ + 'enabled' => true, + ]), + ])); $dispatcher = new HttpWebhookDispatcher; $dispatcher->dispatch($app, 'channel_occupied', ['channel' => 'test-channel']); @@ -355,11 +388,13 @@ public function testBatchingClearsANewlyOwnedLockWhenQueueDispatchFails(): void ->with(m::type(FlushWebhookBatchJob::class)) ->andThrow($failure); $this->app->instance(BusDispatcher::class, $bus); - $app = $this->makeApp(webhooks: [ + $app = $this->makeApp(webhooks: array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => ['channel_occupied'], - 'batching' => ['enabled' => true], - ]); + 'batching' => array_replace($this->webhookConfig()['batching'], [ + 'enabled' => true, + ]), + ])); $dispatcher = new HttpWebhookDispatcher; try { @@ -379,11 +414,13 @@ public function testBatchingDoesNotClearALockOwnedByAnotherDispatch(): void $bus = m::mock(BusDispatcher::class); $bus->shouldNotReceive('dispatch'); $this->app->instance(BusDispatcher::class, $bus); - $app = $this->makeApp(webhooks: [ + $app = $this->makeApp(webhooks: array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => ['channel_occupied'], - 'batching' => ['enabled' => true], - ]); + 'batching' => array_replace($this->webhookConfig()['batching'], [ + 'enabled' => true, + ]), + ])); (new HttpWebhookDispatcher)->dispatch( $app, @@ -398,11 +435,13 @@ public function testImmediateDispatchWhenBatchingDisabled(): void { Queue::fake(); - $app = $this->makeApp(webhooks: [ + $app = $this->makeApp(webhooks: array_replace($this->webhookConfig(), [ 'url' => 'https://example.com/webhook', 'events' => ['channel_occupied'], - 'batching' => ['enabled' => false], - ]); + 'batching' => array_replace($this->webhookConfig()['batching'], [ + 'enabled' => false, + ]), + ])); $dispatcher = new HttpWebhookDispatcher; $dispatcher->dispatch($app, 'channel_occupied', ['channel' => 'test-channel']); @@ -410,11 +449,34 @@ public function testImmediateDispatchWhenBatchingDisabled(): void Queue::assertPushed(WebhookDeliveryJob::class); } + public function testMissingWebhookTimeoutFailsLoudly(): void + { + Queue::fake(); + + $webhooks = array_replace($this->webhookConfig(), [ + 'url' => 'https://example.com/webhook', + 'events' => ['channel_occupied'], + ]); + unset($webhooks['timeout']); + + $this->expectException(ErrorException::class); + $this->expectExceptionMessage('Undefined array key "timeout"'); + + (new HttpWebhookDispatcher)->dispatch( + $this->makeApp(webhooks: $webhooks), + 'channel_occupied', + ['channel' => 'test-channel'], + ); + } + public function testSubscriptionCountEventIncludesCountInPayload(): void { Queue::fake(); - $app = $this->makeApp(webhooks: ['url' => 'https://example.com/webhook', 'events' => []]); + $app = $this->makeApp(webhooks: array_replace($this->webhookConfig(), [ + 'url' => 'https://example.com/webhook', + 'events' => [], + ])); $dispatcher = new HttpWebhookDispatcher; $dispatcher->dispatch($app, 'subscription_count', [ @@ -436,7 +498,10 @@ public function testSubscriptionCountBypassesEventsAllowlist(): void Queue::fake(); // Events list only has channel_occupied — subscription_count is NOT listed - $app = $this->makeApp(webhooks: ['url' => 'https://example.com/webhook', 'events' => ['channel_occupied']]); + $app = $this->makeApp(webhooks: array_replace($this->webhookConfig(), [ + 'url' => 'https://example.com/webhook', + 'events' => ['channel_occupied'], + ])); $dispatcher = new HttpWebhookDispatcher; $dispatcher->dispatch($app, 'subscription_count', [ diff --git a/tests/Reverb/Webhooks/Jobs/FlushWebhookBatchJobTest.php b/tests/Reverb/Webhooks/Jobs/FlushWebhookBatchJobTest.php index 82a39ea0f..9d373d097 100644 --- a/tests/Reverb/Webhooks/Jobs/FlushWebhookBatchJobTest.php +++ b/tests/Reverb/Webhooks/Jobs/FlushWebhookBatchJobTest.php @@ -176,18 +176,11 @@ public function testUsesFlushQueueNotDeliveryQueue(): void */ protected function defaultWebhookConfig(): array { - return [ - 'url' => 'https://example.com/webhook', - 'events' => ['channel_occupied', 'channel_vacated'], - 'headers' => [], - 'batching' => [ - 'enabled' => true, - 'max_events' => 50, - 'max_payload_bytes' => 262144, - ], - 'retries' => 3, - 'retry_delay' => 1, - 'timeout' => 5, - ]; + $config = $this->webhookConfig(); + $config['url'] = 'https://example.com/webhook'; + $config['events'] = ['channel_occupied', 'channel_vacated']; + $config['batching']['enabled'] = true; + + return $config; } } From 506c325a9bee801456e7e41de03d3277fc0639e5 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:42:44 +0000 Subject: [PATCH 022/109] sanctum: type token and middleware configuration Normalize env-backed cache and last-used flags, read required routes, prefixes, domains, and token settings through typed access, and treat the middleware block as a complete fixed record. Preserve explicit null middleware removal and the package's nullable expiration, cache-store, and validated timing behavior. Expand coverage for shipped defaults, null middleware entries, incomplete middleware blocks, token caching, authentication flows, and service-provider registration, and update the stateful-domain documentation example. --- src/docs/sanctum.md | 2 +- src/sanctum/config/sanctum.php | 31 +++++++++--- .../src/Console/Commands/PruneExpired.php | 2 +- src/sanctum/src/HasApiTokens.php | 2 +- .../Http/Middleware/AuthenticateSession.php | 2 +- .../EnsureFrontendRequestsAreStateful.php | 10 ++-- src/sanctum/src/PersonalAccessToken.php | 24 ++++----- .../src/PersonalAccessTokenRelation.php | 2 +- src/sanctum/src/SanctumServiceProvider.php | 6 +-- tests/Sanctum/ActingAsTest.php | 11 ++++ tests/Sanctum/AuthenticateRequestsTest.php | 5 ++ tests/Sanctum/AuthenticateSessionTest.php | 27 ++++++++++ .../EnsureFrontendRequestsAreStatefulTest.php | 50 +++++++++++++++++++ tests/Sanctum/GuardTest.php | 19 +++++++ .../Sanctum/PersonalAccessTokenCacheTest.php | 9 ++++ tests/Sanctum/SanctumConfigTest.php | 19 +++++++ tests/Sanctum/SanctumServiceProviderTest.php | 24 ++++++++- tests/Sanctum/SimpleGuardTest.php | 2 + 18 files changed, 215 insertions(+), 32 deletions(-) diff --git a/src/docs/sanctum.md b/src/docs/sanctum.md index 560ace0ea..61ce735bd 100644 --- a/src/docs/sanctum.md +++ b/src/docs/sanctum.md @@ -514,7 +514,7 @@ public function boot(): void EnsureFrontendRequestsAreStateful::resolveStatefulDomainsUsing(function (Request $request): array { $tenant = app(TenantResolver::class)->forRequest($request); - return $tenant ? [$tenant->domain] : config('sanctum.stateful_domains', []); + return $tenant ? [$tenant->domain] : config()->array('sanctum.stateful_domains'); }); } ``` diff --git a/src/sanctum/config/sanctum.php b/src/sanctum/config/sanctum.php index c3b3dddd1..164768df9 100644 --- a/src/sanctum/config/sanctum.php +++ b/src/sanctum/config/sanctum.php @@ -28,6 +28,7 @@ | This value controls the number of minutes until an issued token will be | considered expired. This will override any values set in the token's | "expires_at" attribute, but first-party sessions are not affected. + | Set to null to rely only on each token's expires_at value. | */ @@ -43,7 +44,7 @@ | */ - 'last_used_at' => env('SANCTUM_LAST_USED_AT', true), + 'last_used_at' => (bool) env('SANCTUM_LAST_USED_AT', true), /* |-------------------------------------------------------------------------- @@ -67,7 +68,8 @@ | | When authenticating your first-party SPA with Sanctum you may need to | customize some of the middleware Sanctum uses while processing the - | request. You may change the middleware below as required. + | request. You may change the middleware below as required. Set an entry + | to null to omit that middleware from the stateful request pipeline. | */ @@ -85,20 +87,35 @@ | When enabled, Sanctum will cache token and tokenable lookups to improve | performance. The last_used_at timestamp will be updated at the specified | interval instead of on every request to reduce database writes. The TTL - | is the maximum time a cached tokenable identity may remain stale. + | is the maximum time a cached tokenable identity may remain stale. A + | null store uses the default cache store. The update interval accepts + | zero to write the last-used timestamp after every authentication. | */ 'cache' => [ - 'enabled' => env('SANCTUM_CACHE_ENABLED', false), - 'store' => env('SANCTUM_CACHE_STORE'), // Uses default store if not set - 'ttl' => (int) env('SANCTUM_CACHE_TTL', 300), // 5 minutes + 'enabled' => (bool) env('SANCTUM_CACHE_ENABLED', false), + 'store' => env('SANCTUM_CACHE_STORE'), + 'ttl' => (int) env('SANCTUM_CACHE_TTL', 300), 'prefix' => env('SANCTUM_CACHE_PREFIX', 'sanctum'), - // Zero is valid, so preserve malformed values for startup validation. 'last_used_at_update_interval' => filter_var( env('SANCTUM_LAST_USED_UPDATE_INTERVAL', 300), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE, ), ], + + /* + |-------------------------------------------------------------------------- + | Routes + |-------------------------------------------------------------------------- + | + | Disable route registration when the application provides its own CSRF + | cookie endpoint. The prefix applies to Sanctum's built-in route. + | + */ + + 'routes' => true, + + 'prefix' => 'sanctum', ]; diff --git a/src/sanctum/src/Console/Commands/PruneExpired.php b/src/sanctum/src/Console/Commands/PruneExpired.php index e68dfc446..c73219f56 100644 --- a/src/sanctum/src/Console/Commands/PruneExpired.php +++ b/src/sanctum/src/Console/Commands/PruneExpired.php @@ -47,7 +47,7 @@ public function handle(): int $expiredCount = $model::where('expires_at', '<', now()->subHours($hours))->delete(); $this->info("Pruned {$expiredCount} expired tokens."); - if ($expiration = config('sanctum.expiration')) { + if ($expiration = config()->get('sanctum.expiration')) { $this->info('Pruning tokens with expired expiration value based on configuration file...'); $configExpiredCount = $model::where('created_at', '<', now()->subMinutes($expiration + ($hours * 60)))->delete(); diff --git a/src/sanctum/src/HasApiTokens.php b/src/sanctum/src/HasApiTokens.php index 5cadd174e..a538ef430 100644 --- a/src/sanctum/src/HasApiTokens.php +++ b/src/sanctum/src/HasApiTokens.php @@ -97,7 +97,7 @@ public function generateTokenString(): string { return sprintf( '%s%s%s', - config('sanctum.token_prefix', ''), + config()->string('sanctum.token_prefix'), $tokenEntropy = Str::random(40), hash('crc32b', $tokenEntropy) ); diff --git a/src/sanctum/src/Http/Middleware/AuthenticateSession.php b/src/sanctum/src/Http/Middleware/AuthenticateSession.php index f5e229a8c..dd89ff851 100644 --- a/src/sanctum/src/Http/Middleware/AuthenticateSession.php +++ b/src/sanctum/src/Http/Middleware/AuthenticateSession.php @@ -100,7 +100,7 @@ protected function sanctumSessionGuards(): array { $sessionGuards = []; - foreach ($this->config->array('auth.guards', []) as $guard) { + foreach ($this->config->array('auth.guards') as $guard) { if (! is_array($guard) || ($guard['driver'] ?? null) !== 'sanctum') { continue; } diff --git a/src/sanctum/src/Http/Middleware/EnsureFrontendRequestsAreStateful.php b/src/sanctum/src/Http/Middleware/EnsureFrontendRequestsAreStateful.php index edb4563b7..cc8faa148 100644 --- a/src/sanctum/src/Http/Middleware/EnsureFrontendRequestsAreStateful.php +++ b/src/sanctum/src/Http/Middleware/EnsureFrontendRequestsAreStateful.php @@ -50,12 +50,14 @@ public function handle(Request $request, Closure $next): Response */ protected function frontendMiddleware(): array { + $configuredMiddleware = config()->array('sanctum.middleware'); + $middleware = [ - config('sanctum.middleware.encrypt_cookies', \Hypervel\Cookie\Middleware\EncryptCookies::class), + $configuredMiddleware['encrypt_cookies'], \Hypervel\Cookie\Middleware\AddQueuedCookiesToResponse::class, \Hypervel\Session\Middleware\StartSession::class, - config('sanctum.middleware.validate_csrf_token', \Hypervel\Foundation\Http\Middleware\PreventRequestForgery::class), - config('sanctum.middleware.authenticate_session'), + $configuredMiddleware['validate_csrf_token'], + $configuredMiddleware['authenticate_session'], ]; $filtered = []; @@ -116,7 +118,7 @@ private static function resolveStatefulDomains(Request $request): array return self::filterDomainList((static::$statefulDomainsResolver)($request)); } - return self::filterDomainList(config('sanctum.stateful_domains', [])); + return self::filterDomainList(config()->array('sanctum.stateful_domains')); } /** diff --git a/src/sanctum/src/PersonalAccessToken.php b/src/sanctum/src/PersonalAccessToken.php index 8fb905c6a..f7be9d512 100644 --- a/src/sanctum/src/PersonalAccessToken.php +++ b/src/sanctum/src/PersonalAccessToken.php @@ -69,7 +69,7 @@ protected static function boot(): void parent::boot(); static::created(function (self $token): void { - if (! config('sanctum.cache.enabled')) { + if (! config()->boolean('sanctum.cache.enabled')) { return; } @@ -82,7 +82,7 @@ protected static function boot(): void }); static::updated(function (self $token): void { - if (! config('sanctum.cache.enabled')) { + if (! config()->boolean('sanctum.cache.enabled')) { return; } @@ -102,7 +102,7 @@ protected static function boot(): void }); static::deleted(function (self $token): void { - if (! config('sanctum.cache.enabled')) { + if (! config()->boolean('sanctum.cache.enabled')) { return; } @@ -144,7 +144,7 @@ public static function findToken(string $token): ?static return null; } - $accessToken = config('sanctum.cache.enabled') + $accessToken = config()->boolean('sanctum.cache.enabled') ? static::findTokenUsingCache($id) : static::find($id); @@ -168,7 +168,7 @@ protected static function findTokenUsingCache(string $id): ?static return $cache->rememberNullable( static::getCacheKey($id), - config('sanctum.cache.ttl'), + config()->get('sanctum.cache.ttl'), fn () => static::find($id)?->unsetRelation('tokenable') ); } @@ -182,7 +182,7 @@ public static function findTokenable(PersonalAccessToken $accessToken): ?Authent return $accessToken->getRelation('tokenable'); } - if (! config('sanctum.cache.enabled')) { + if (! config()->boolean('sanctum.cache.enabled')) { return $accessToken->getAttribute('tokenable'); } @@ -198,7 +198,7 @@ public static function findTokenable(PersonalAccessToken $accessToken): ?Authent $tokenable = $accessToken->getAttribute('tokenable'); if ($tokenable instanceof Authenticatable) { - $cache->put($cacheKey, $tokenable, config('sanctum.cache.ttl')); + $cache->put($cacheKey, $tokenable, config()->get('sanctum.cache.ttl')); } else { $tokenable = null; } @@ -252,13 +252,13 @@ protected static function forgetTokenEntry(CacheRepository $cache, int|string $t public function updateLastUsedAt(): void { $now = now(); - $cacheEnabled = (bool) config('sanctum.cache.enabled'); + $cacheEnabled = config()->boolean('sanctum.cache.enabled'); if ( $cacheEnabled && $this->last_used_at !== null && $this->last_used_at->diffInSeconds($now) - < config('sanctum.cache.last_used_at_update_interval') + < config()->get('sanctum.cache.last_used_at_update_interval') ) { return; } @@ -282,7 +282,7 @@ public function updateLastUsedAt(): void /** @var int|string $id */ $id = $this->getKey(); $snapshot = $this->withoutRelation('tokenable'); - $ttl = config('sanctum.cache.ttl'); + $ttl = config()->get('sanctum.cache.ttl'); $this->settleCacheMutation( fn () => static::getCache()->put(static::getCacheKey($id), $snapshot, $ttl) @@ -326,7 +326,7 @@ protected function settleCacheMutation(Closure $callback): void protected static function getCache(): CacheRepository { $cacheManager = Container::getInstance()->make('cache'); - $store = config('sanctum.cache.store'); + $store = config()->get('sanctum.cache.store'); return $store !== null && $store !== '' ? $cacheManager->store($store) @@ -338,7 +338,7 @@ protected static function getCache(): CacheRepository */ protected static function getCacheKey(int|string $tokenId): string { - $prefix = config('sanctum.cache.prefix'); + $prefix = config()->string('sanctum.cache.prefix'); return "{$prefix}:{$tokenId}"; } } diff --git a/src/sanctum/src/PersonalAccessTokenRelation.php b/src/sanctum/src/PersonalAccessTokenRelation.php index 0e5d0f471..8d14b60c0 100644 --- a/src/sanctum/src/PersonalAccessTokenRelation.php +++ b/src/sanctum/src/PersonalAccessTokenRelation.php @@ -19,7 +19,7 @@ class PersonalAccessTokenRelation extends MorphMany */ public function delete(): mixed { - if (! config('sanctum.cache.enabled')) { + if (! config()->boolean('sanctum.cache.enabled')) { return $this->getQuery()->delete(); } diff --git a/src/sanctum/src/SanctumServiceProvider.php b/src/sanctum/src/SanctumServiceProvider.php index 7e37e5f8f..c26fcb5f1 100644 --- a/src/sanctum/src/SanctumServiceProvider.php +++ b/src/sanctum/src/SanctumServiceProvider.php @@ -160,11 +160,11 @@ protected function defineRoutes(): void $config = $this->app->make(ConfigRepository::class); - if (! $config->boolean('sanctum.routes', true)) { + if (! $config->boolean('sanctum.routes')) { return; } - Route::group(['prefix' => $config->string('sanctum.prefix', 'sanctum')], function (): void { + Route::group(['prefix' => $config->string('sanctum.prefix')], function (): void { Route::get('/csrf-cookie', [CsrfCookieController::class, 'show']) ->middleware('web') ->name('sanctum.csrf-cookie'); @@ -214,7 +214,7 @@ protected function createGuard( return new SanctumGuard( name: $name, - provider: $authManager->createUserProvider($config['provider'] ?? null), + provider: $authManager->createUserProvider($config['provider']), app: $app, sessionGuards: $sessionGuards, events: $app->bound('events') ? $app->make('events') : null, diff --git a/tests/Sanctum/ActingAsTest.php b/tests/Sanctum/ActingAsTest.php index 8caaf596c..e480e9118 100644 --- a/tests/Sanctum/ActingAsTest.php +++ b/tests/Sanctum/ActingAsTest.php @@ -27,15 +27,26 @@ protected function defineEnvironment(ApplicationContract $app): void 'driver' => 'sanctum', 'provider' => 'users', 'session_guards' => ['web'], + 'passwords' => null, + 'password_timeout' => null, ], 'auth.guards.api' => [ 'driver' => 'sanctum', 'provider' => 'users', 'session_guards' => ['web'], + 'passwords' => null, + 'password_timeout' => null, ], 'auth.providers.users' => [ 'driver' => 'eloquent', 'model' => User::class, + 'cache' => [ + 'enabled' => false, + 'store' => null, + 'ttl' => 300, + 'prefix' => 'auth_users', + 'tags' => null, + ], ], ]); } diff --git a/tests/Sanctum/AuthenticateRequestsTest.php b/tests/Sanctum/AuthenticateRequestsTest.php index b82fd69ef..ffd69b642 100644 --- a/tests/Sanctum/AuthenticateRequestsTest.php +++ b/tests/Sanctum/AuthenticateRequestsTest.php @@ -44,10 +44,15 @@ protected function defineEnvironment(ApplicationContract $app): void 'driver' => 'sanctum', 'provider' => 'users', 'session_guards' => ['web'], + 'passwords' => null, + 'password_timeout' => null, ], 'auth.guards.web' => [ 'driver' => 'session', 'provider' => 'users', + 'passwords' => 'users', + 'password_timeout' => null, + 'remember' => null, ], 'auth.providers.users.model' => TestUser::class, 'auth.providers.users.driver' => 'eloquent', diff --git a/tests/Sanctum/AuthenticateSessionTest.php b/tests/Sanctum/AuthenticateSessionTest.php index 31a9657c3..491e08e2f 100644 --- a/tests/Sanctum/AuthenticateSessionTest.php +++ b/tests/Sanctum/AuthenticateSessionTest.php @@ -27,14 +27,27 @@ protected function defineEnvironment(ApplicationContract $app): void 'auth.guards.web' => [ 'driver' => 'session', 'provider' => 'users', + 'passwords' => null, + 'password_timeout' => null, + 'remember' => null, ], 'auth.guards.admin' => [ 'driver' => 'session', 'provider' => 'users', + 'passwords' => null, + 'password_timeout' => null, + 'remember' => null, ], 'auth.providers.users' => [ 'driver' => 'eloquent', 'model' => TestUser::class, + 'cache' => [ + 'enabled' => false, + 'store' => null, + 'ttl' => 300, + 'prefix' => 'auth_users', + 'tags' => null, + ], ], ]); } @@ -46,11 +59,15 @@ public function testUnionOfSanctumGuardsSessionGuardsIsChecked(): void 'driver' => 'sanctum', 'provider' => 'users', 'session_guards' => ['web'], + 'passwords' => null, + 'password_timeout' => null, ]); $config->set('auth.guards.admin-api', [ 'driver' => 'sanctum', 'provider' => 'users', 'session_guards' => ['admin'], + 'passwords' => null, + 'password_timeout' => null, ]); $this->app->make('auth')->forgetGuards(); @@ -256,6 +273,8 @@ public function testSanctumEntryWithoutSessionGuardsContributesNothing(): void $this->app->make('config')->set('auth.guards.sanctum', [ 'driver' => 'sanctum', 'provider' => 'users', + 'passwords' => null, + 'password_timeout' => null, ]); $this->app->make('auth')->forgetGuards(); @@ -273,11 +292,15 @@ public function testMalformedSessionGuardsEntriesAreSkippedByUnion(): void 'driver' => 'sanctum', 'provider' => 'users', 'session_guards' => 'web', + 'passwords' => null, + 'password_timeout' => null, ]); $config->set('auth.guards.sanctum', [ 'driver' => 'sanctum', 'provider' => 'users', 'session_guards' => [123, '', 'admin'], + 'passwords' => null, + 'password_timeout' => null, ]); $auth = $this->app->make('auth'); @@ -308,11 +331,15 @@ private function configureWebAndAdminSanctumGuards(): void 'driver' => 'sanctum', 'provider' => 'users', 'session_guards' => ['web'], + 'passwords' => null, + 'password_timeout' => null, ]); $config->set('auth.guards.admin-api', [ 'driver' => 'sanctum', 'provider' => 'users', 'session_guards' => ['admin'], + 'passwords' => null, + 'password_timeout' => null, ]); } diff --git a/tests/Sanctum/EnsureFrontendRequestsAreStatefulTest.php b/tests/Sanctum/EnsureFrontendRequestsAreStatefulTest.php index 942f40251..3e90b0930 100644 --- a/tests/Sanctum/EnsureFrontendRequestsAreStatefulTest.php +++ b/tests/Sanctum/EnsureFrontendRequestsAreStatefulTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Sanctum; use Closure; +use ErrorException; use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Cookie\Middleware\AddQueuedCookiesToResponse; use Hypervel\Http\Request; @@ -12,6 +13,7 @@ use Hypervel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful; use Hypervel\Session\Middleware\StartSession; use Hypervel\Testbench\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; use TypeError; class EnsureFrontendRequestsAreStatefulTest extends TestCase @@ -217,6 +219,54 @@ public function testFrontendMiddlewareOnlyOmitsMissingEntriesAndDeduplicatesStri false, ], array_slice($middleware, 1)); } + + public function testNullCookieAndCsrfMiddlewareEntriesAreOmittedWithoutFallbacks(): void + { + config([ + 'sanctum.middleware' => [ + 'encrypt_cookies' => null, + 'validate_csrf_token' => null, + 'authenticate_session' => 'custom-authenticate-session', + ], + ]); + + $middleware = (new EnsureFrontendRequestsAreStatefulFixture)->middleware(); + + $this->assertSame([ + AddQueuedCookiesToResponse::class, + StartSession::class, + 'custom-authenticate-session', + ], array_slice($middleware, 1)); + } + + #[DataProvider('middlewareMemberProvider')] + public function testMissingMiddlewareMembersFailInsteadOfSilentlyRemovingProtection(string $missingMember): void + { + $middleware = [ + 'encrypt_cookies' => 'encrypt-cookies', + 'validate_csrf_token' => 'validate-csrf-token', + 'authenticate_session' => 'authenticate-session', + ]; + unset($middleware[$missingMember]); + config(['sanctum.middleware' => $middleware]); + + $this->expectException(ErrorException::class); + $this->expectExceptionMessage('Undefined array key "' . $missingMember . '"'); + + (new EnsureFrontendRequestsAreStatefulFixture)->middleware(); + } + + /** + * Provide required Sanctum middleware members. + */ + public static function middlewareMemberProvider(): array + { + return [ + 'cookie encryption' => ['encrypt_cookies'], + 'CSRF validation' => ['validate_csrf_token'], + 'session authentication' => ['authenticate_session'], + ]; + } } class EnsureFrontendRequestsAreStatefulFixture extends EnsureFrontendRequestsAreStateful diff --git a/tests/Sanctum/GuardTest.php b/tests/Sanctum/GuardTest.php index ab8755f96..7eb471f6f 100644 --- a/tests/Sanctum/GuardTest.php +++ b/tests/Sanctum/GuardTest.php @@ -75,10 +75,15 @@ protected function defineEnvironment(ApplicationContract $app): void 'driver' => 'sanctum', 'provider' => 'users', 'session_guards' => ['web'], + 'passwords' => null, + 'password_timeout' => null, ], 'auth.guards.web' => [ 'driver' => 'session', 'provider' => 'users', + 'passwords' => 'users', + 'password_timeout' => null, + 'remember' => null, ], 'auth.providers.users.model' => TestUser::class, 'auth.providers.users.driver' => 'eloquent', @@ -486,6 +491,8 @@ public function testMissingSessionGuardsThrowsInstructiveError(): void $this->app->make('config')->set('auth.guards.sanctum', [ 'driver' => 'sanctum', 'provider' => 'users', + 'passwords' => null, + 'password_timeout' => null, ]); $this->app->make('auth')->forgetGuards(); @@ -531,6 +538,8 @@ public function testNonStatefulSessionGuardThrows(): void 'driver' => 'sanctum', 'provider' => 'users', 'session_guards' => [], + 'passwords' => null, + 'password_timeout' => null, ]); $this->app->make('auth')->forgetGuards(); @@ -548,6 +557,13 @@ public function testStatefulUserMustMatchProvider(): void $config->set('auth.providers.admins', [ 'driver' => 'eloquent', 'model' => SanctumTestUser::class, + 'cache' => [ + 'enabled' => false, + 'store' => null, + 'ttl' => 300, + 'prefix' => 'auth_users', + 'tags' => null, + ], ]); $config->set('auth.guards.web.provider', 'admins'); $this->app->make('auth')->forgetGuards(); @@ -578,6 +594,9 @@ public function testSecondListedSessionGuardIsTried(): void $config->set('auth.guards.admin', [ 'driver' => 'session', 'provider' => 'users', + 'passwords' => 'users', + 'password_timeout' => null, + 'remember' => null, ]); $config->set('auth.guards.sanctum.session_guards', ['admin', 'web']); $this->app->make('auth')->forgetGuards(); diff --git a/tests/Sanctum/PersonalAccessTokenCacheTest.php b/tests/Sanctum/PersonalAccessTokenCacheTest.php index 611325fb2..c2339fef9 100644 --- a/tests/Sanctum/PersonalAccessTokenCacheTest.php +++ b/tests/Sanctum/PersonalAccessTokenCacheTest.php @@ -78,10 +78,19 @@ protected function defineEnvironment(ApplicationContract $app): void 'driver' => 'sanctum', 'provider' => 'users', 'session_guards' => [], + 'passwords' => null, + 'password_timeout' => null, ], 'auth.providers.users' => [ 'driver' => 'eloquent', 'model' => TestUser::class, + 'cache' => [ + 'enabled' => false, + 'store' => null, + 'ttl' => 300, + 'prefix' => 'auth_users', + 'tags' => null, + ], ], 'database.connections.sanctum_secondary' => [ 'driver' => 'sqlite', diff --git a/tests/Sanctum/SanctumConfigTest.php b/tests/Sanctum/SanctumConfigTest.php index baf83cf5f..76d7d1ecb 100644 --- a/tests/Sanctum/SanctumConfigTest.php +++ b/tests/Sanctum/SanctumConfigTest.php @@ -20,6 +20,17 @@ public function testCacheIntervalsAreLoadedAsIntegersFromEnvironment(): void $this->assertSame(120, $config['cache']['last_used_at_update_interval']); } + public function testBooleanEnvironmentValuesAreLoadedAsBooleans(): void + { + $config = $this->loadConfigWithEnvironmentValues([ + 'SANCTUM_LAST_USED_AT' => '0', + 'SANCTUM_CACHE_ENABLED' => '1', + ]); + + $this->assertFalse($config['last_used_at']); + $this->assertTrue($config['cache']['enabled']); + } + public function testInvalidLastUsedUpdateIntervalRemainsInvalid(): void { $config = $this->loadConfigWithEnvironmentValues([ @@ -38,6 +49,14 @@ public function testNullStatefulDomainsDoNotCrashConfigLoading(): void $this->assertSame([''], $config['stateful_domains']); } + public function testRouteDefaultsAreDeclared(): void + { + $config = $this->loadConfigWithEnvironmentValues([]); + + $this->assertTrue($config['routes']); + $this->assertSame('sanctum', $config['prefix']); + } + /** * Load the Sanctum configuration with temporary environment values. * diff --git a/tests/Sanctum/SanctumServiceProviderTest.php b/tests/Sanctum/SanctumServiceProviderTest.php index b835faa45..93605c70e 100644 --- a/tests/Sanctum/SanctumServiceProviderTest.php +++ b/tests/Sanctum/SanctumServiceProviderTest.php @@ -58,6 +58,13 @@ public function testBootContributesLateTokenModelConfiguredGuardModelsAndFramewo 'users' => [ 'driver' => 'eloquent', 'model' => SanctumProviderUser::class, + 'cache' => [ + 'enabled' => false, + 'store' => null, + 'ttl' => 300, + 'prefix' => 'auth_users', + 'tags' => null, + ], ], 'custom' => [ 'driver' => 'custom', @@ -257,6 +264,13 @@ public function testSelectedProviderRequiresAnEloquentAuthenticatableModel(): vo 'users' => [ 'driver' => 'eloquent', 'model' => InvalidSanctumProviderModel::class, + 'cache' => [ + 'enabled' => false, + 'store' => null, + 'ttl' => 300, + 'prefix' => 'auth_users', + 'tags' => null, + ], ], ], ], @@ -288,12 +302,20 @@ public function testDefineRoutesRequiresExactConfigurationTypes( mixed $value, string $message, ): void { + $config = new ConfigRepository([ + 'sanctum' => [ + 'routes' => true, + 'prefix' => 'sanctum', + ], + ]); + $config->set($key, $value); + $application = m::mock(Application::class); $application->shouldReceive('routesAreCached')->once()->andReturnFalse(); $application->shouldReceive('make') ->once() ->with(ConfigRepositoryContract::class) - ->andReturn(new ConfigRepository([$key => $value])); + ->andReturn($config); $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage($message); diff --git a/tests/Sanctum/SimpleGuardTest.php b/tests/Sanctum/SimpleGuardTest.php index 5f2de7d4e..227de05a4 100644 --- a/tests/Sanctum/SimpleGuardTest.php +++ b/tests/Sanctum/SimpleGuardTest.php @@ -56,6 +56,8 @@ protected function defineEnvironment(ApplicationContract $app): void 'driver' => 'sanctum', 'provider' => 'users', 'session_guards' => ['web'], + 'passwords' => null, + 'password_timeout' => null, ], 'auth.providers.users.model' => TestUser::class, 'auth.providers.users.driver' => 'eloquent', From 4727616b1cd805680c354e8337e6359bdb6c2744 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:42:50 +0000 Subject: [PATCH 023/109] scout: use typed package configuration directly Normalize queue and command-concurrency env values, read required Scout settings through typed helpers, and remove the obsolete Hyperf-era getScoutConfig extension helpers from Searchable and CollectionEngine. Keep nullable queue, Algolia timeout, Meilisearch key, and index-setting behavior where downstream workers or SDKs own it. Add config-file and provider coverage for numeric concurrency, boolean queueing, Algolia SDK defaults and overrides, Meilisearch retries, console commands, and shipped-record resolution. --- src/scout/config/scout.php | 9 ++- .../src/Console/DeleteAllIndexesCommand.php | 2 +- src/scout/src/Console/DeleteIndexCommand.php | 2 +- src/scout/src/Console/IndexCommand.php | 4 +- src/scout/src/Console/QueueImportCommand.php | 2 +- .../src/Console/SyncIndexSettingsCommand.php | 4 +- src/scout/src/Engines/CollectionEngine.php | 13 +--- src/scout/src/ModelObserver.php | 4 +- src/scout/src/ScoutServiceProvider.php | 8 +-- src/scout/src/Searchable.php | 34 ++++------ src/scout/src/SearchableScope.php | 4 +- tests/Scout/Unit/ConfigFileTest.php | 47 +++++++++++++- .../Console/DeleteAllIndexesCommandTest.php | 4 +- tests/Scout/Unit/Console/IndexCommandTest.php | 10 +-- .../Console/SyncIndexSettingsCommandTest.php | 12 ++-- tests/Scout/Unit/ScoutServiceProviderTest.php | 63 +++++++++++++++++++ 16 files changed, 157 insertions(+), 65 deletions(-) diff --git a/src/scout/config/scout.php b/src/scout/config/scout.php index da6fc062e..55d776232 100644 --- a/src/scout/config/scout.php +++ b/src/scout/config/scout.php @@ -48,7 +48,7 @@ */ 'queue' => [ - 'enabled' => env('SCOUT_QUEUE', false), + 'enabled' => (bool) env('SCOUT_QUEUE', false), 'connection' => env('SCOUT_QUEUE_CONNECTION'), 'queue' => env('SCOUT_QUEUE_NAME'), ], @@ -110,7 +110,7 @@ | */ - 'command_concurrency' => env('SCOUT_COMMAND_CONCURRENCY', 50), + 'command_concurrency' => (int) env('SCOUT_COMMAND_CONCURRENCY', 50), /* |-------------------------------------------------------------------------- @@ -148,12 +148,17 @@ | Here you may configure your Algolia settings. Algolia is a cloud hosted | search engine which works great with Scout out of the box. Just plug | in your application ID and admin API key to get started searching. + | Timeout values are measured in seconds; null leaves the corresponding + | Algolia SDK default unchanged. | */ 'algolia' => [ 'id' => env('ALGOLIA_APP_ID', ''), 'secret' => env('ALGOLIA_SECRET', ''), + 'connect_timeout' => null, + 'read_timeout' => null, + 'write_timeout' => null, 'index-settings' => [ // Per-index settings can be defined here: // 'users' => [ diff --git a/src/scout/src/Console/DeleteAllIndexesCommand.php b/src/scout/src/Console/DeleteAllIndexesCommand.php index bc8d9d2dc..cf6fd4bf6 100644 --- a/src/scout/src/Console/DeleteAllIndexesCommand.php +++ b/src/scout/src/Console/DeleteAllIndexesCommand.php @@ -35,7 +35,7 @@ public function handle(EngineManager $manager, Repository $config): int // Gate safety first, before resolving the engine. If prefix is empty // and --force isn't set, we refuse without ever instantiating the // driver's underlying client. - $prefix = $config->string('scout.prefix', ''); + $prefix = $config->string('scout.prefix'); $force = (bool) $this->option('force'); if ($prefix === '' && ! $force) { diff --git a/src/scout/src/Console/DeleteIndexCommand.php b/src/scout/src/Console/DeleteIndexCommand.php index a644618cf..9d3898e4a 100644 --- a/src/scout/src/Console/DeleteIndexCommand.php +++ b/src/scout/src/Console/DeleteIndexCommand.php @@ -50,7 +50,7 @@ protected function indexName(string $name, Repository $config): string return (new $name)->indexableAs(); } - $prefix = $config->string('scout.prefix', ''); + $prefix = $config->string('scout.prefix'); return ! Str::startsWith($name, $prefix) ? $prefix . $name : $name; } diff --git a/src/scout/src/Console/IndexCommand.php b/src/scout/src/Console/IndexCommand.php index d757f293c..cebb6556c 100644 --- a/src/scout/src/Console/IndexCommand.php +++ b/src/scout/src/Console/IndexCommand.php @@ -69,7 +69,7 @@ public function handle(EngineManager $manager, Repository $config): int ?? []; if ($model !== null - && $config->boolean('scout.soft_delete', false) + && $config->boolean('scout.soft_delete') && in_array(SoftDeletes::class, class_uses_recursive($model), true)) { $settings = $engine->configureSoftDeleteFilter($settings); } @@ -109,7 +109,7 @@ protected function indexName(string $name, Repository $config): string return (new $name)->indexableAs(); } - $prefix = $config->string('scout.prefix', ''); + $prefix = $config->string('scout.prefix'); return ! Str::startsWith($name, $prefix) ? $prefix . $name : $name; } diff --git a/src/scout/src/Console/QueueImportCommand.php b/src/scout/src/Console/QueueImportCommand.php index 5f22f6c93..5ed728bd9 100644 --- a/src/scout/src/Console/QueueImportCommand.php +++ b/src/scout/src/Console/QueueImportCommand.php @@ -49,7 +49,7 @@ public function handle(Repository $config): int /** @var Model&SearchableInterface $model */ $model = new $class; - $chunk = max(1, (int) ($this->option('chunk') ?? $config->integer('scout.chunk.searchable', 500))); + $chunk = max(1, (int) ($this->option('chunk') ?? $config->integer('scout.chunk.searchable'))); $queueName = $this->option('queue') ?? $model->syncWithSearchUsingQueue(); $connection = $model->syncWithSearchUsing(); $order = (string) $this->option('order'); diff --git a/src/scout/src/Console/SyncIndexSettingsCommand.php b/src/scout/src/Console/SyncIndexSettingsCommand.php index 0d721836c..ef2acf403 100644 --- a/src/scout/src/Console/SyncIndexSettingsCommand.php +++ b/src/scout/src/Console/SyncIndexSettingsCommand.php @@ -68,7 +68,7 @@ public function handle(EngineManager $manager, Repository $config): int } if ($model !== null - && $config->boolean('scout.soft_delete', false) + && $config->boolean('scout.soft_delete') && in_array(SoftDeletes::class, class_uses_recursive($model), true)) { $settings = $engine->configureSoftDeleteFilter($settings); } @@ -92,7 +92,7 @@ protected function indexName(string $name, Repository $config): string return (new $name)->indexableAs(); } - $prefix = $config->string('scout.prefix', ''); + $prefix = $config->string('scout.prefix'); return ! Str::startsWith($name, $prefix) ? $prefix . $name : $name; } diff --git a/src/scout/src/Engines/CollectionEngine.php b/src/scout/src/Engines/CollectionEngine.php index e979fd3f9..89e4faf1a 100644 --- a/src/scout/src/Engines/CollectionEngine.php +++ b/src/scout/src/Engines/CollectionEngine.php @@ -4,7 +4,6 @@ namespace Hypervel\Scout\Engines; -use Hypervel\Container\Container; use Hypervel\Database\Eloquent\Builder as EloquentBuilder; use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Database\Eloquent\Model; @@ -179,7 +178,7 @@ protected function ensureSoftDeletesAreHandled(Builder $builder, EloquentBuilder } if (in_array(SoftDeletes::class, class_uses_recursive(get_class($builder->model)), true) - && $this->getScoutConfig('soft_delete', false) + && config()->boolean('scout.soft_delete') ) { /* @phpstan-ignore method.notFound (SoftDeletingScope adds this method) */ return $query->withTrashed(); @@ -291,14 +290,4 @@ public function deleteIndex(string $name): mixed { return null; } - - /** - * Get a Scout configuration value. - */ - protected function getScoutConfig(string $key, mixed $default = null): mixed - { - return Container::getInstance() - ->make('config') - ->get("scout.{$key}", $default); - } } diff --git a/src/scout/src/ModelObserver.php b/src/scout/src/ModelObserver.php index 461c12dbe..da8ac02a9 100644 --- a/src/scout/src/ModelObserver.php +++ b/src/scout/src/ModelObserver.php @@ -45,8 +45,8 @@ class ModelObserver */ public function __construct() { - $this->afterCommit = Config::boolean('scout.after_commit', false); - $this->usingSoftDeletes = Config::boolean('scout.soft_delete', false); + $this->afterCommit = Config::boolean('scout.after_commit'); + $this->usingSoftDeletes = Config::boolean('scout.soft_delete'); } /** diff --git a/src/scout/src/ScoutServiceProvider.php b/src/scout/src/ScoutServiceProvider.php index 8da313689..7848fad23 100644 --- a/src/scout/src/ScoutServiceProvider.php +++ b/src/scout/src/ScoutServiceProvider.php @@ -138,8 +138,8 @@ protected function registerMeilisearchClient(): void // and Typesense's which has num_retries). Add HTTP-level retry at // the Guzzle layer for parity, using MeilisearchRetryPolicy to // decide what to retry and how long to wait between attempts. - $maxRetries = $config->integer('scout.meilisearch.retries', 3); - $baseDelayMs = $config->integer('scout.meilisearch.initial_retry_delay_ms', 100); + $maxRetries = $config->integer('scout.meilisearch.retries'); + $baseDelayMs = $config->integer('scout.meilisearch.initial_retry_delay_ms'); if ($maxRetries > 0) { $stack = HandlerStack::create(); @@ -152,7 +152,7 @@ protected function registerMeilisearchClient(): void // Swoole-unsafe PSR-18 implementation (e.g. Symfony's // CurlHttpClient). Mirrors the Typesense binding's defensive pattern. return new MeilisearchClient( - $config->string('scout.meilisearch.host', 'http://localhost:7700'), + $config->string('scout.meilisearch.host'), $config->get('scout.meilisearch.key'), new GuzzleClient($guzzleOptions), ); @@ -166,7 +166,7 @@ protected function registerTypesenseClient(): void { $this->app->singleton(TypesenseClient::class, function () { $config = $this->app->make('config'); - $settings = $config->array('scout.typesense.client-settings', []); + $settings = $config->array('scout.typesense.client-settings'); // Explicitly inject Guzzle as the HTTP client so Typesense never // falls back to PSR-18 auto-discovery, which may resolve to diff --git a/src/scout/src/Searchable.php b/src/scout/src/Searchable.php index 2619b12d5..22a204df7 100644 --- a/src/scout/src/Searchable.php +++ b/src/scout/src/Searchable.php @@ -95,7 +95,7 @@ public function registerSearchableMacros(): void HasManyThrough::macro('searchable', function (?int $chunk = null): void { /** @var HasManyThrough $this */ - $chunkSize = $chunk ?? config('scout.chunk.searchable', 500); + $chunkSize = $chunk ?? config()->integer('scout.chunk.searchable'); $this->chunkById($chunkSize, function (Collection $models): void { /** @var Collection $models */ @@ -112,7 +112,7 @@ public function registerSearchableMacros(): void HasManyThrough::macro('unsearchable', function (?int $chunk = null): void { /** @var HasManyThrough $this */ - $chunkSize = $chunk ?? config('scout.chunk.unsearchable', 500); + $chunkSize = $chunk ?? config()->integer('scout.chunk.unsearchable'); $this->chunkById($chunkSize, function (Collection $models): void { /** @var Collection $models */ @@ -135,13 +135,13 @@ public function queueMakeSearchable(Collection $models): void return; } - if (! Scout::isImporting() && static::getScoutConfig('queue.enabled', false)) { + if (! Scout::isImporting() && config()->boolean('scout.queue.enabled')) { $jobClass = Scout::$makeSearchableJob; $pendingDispatch = $jobClass::dispatch($models) ->onConnection($models->first()->syncWithSearchUsing()) ->onQueue($models->first()->syncWithSearchUsingQueue()); - if (static::getScoutConfig('after_commit', false)) { + if (config()->boolean('scout.after_commit')) { $pendingDispatch->afterCommit(); } @@ -180,13 +180,13 @@ public function queueRemoveFromSearch(Collection $models): void return; } - if (! Scout::isImporting() && static::getScoutConfig('queue.enabled', false)) { + if (! Scout::isImporting() && config()->boolean('scout.queue.enabled')) { $jobClass = Scout::$removeFromSearchJob; $pendingDispatch = $jobClass::dispatch($models) ->onConnection($models->first()->syncWithSearchUsing()) ->onQueue($models->first()->syncWithSearchUsingQueue()); - if (static::getScoutConfig('after_commit', false)) { + if (config()->boolean('scout.after_commit')) { $pendingDispatch->afterCommit(); } @@ -242,7 +242,7 @@ public static function search(string $query = '', ?Closure $callback = null): Bu 'model' => new static, 'query' => $query, 'callback' => $callback, - 'softDelete' => static::usesSoftDelete() && static::getScoutConfig('soft_delete', false), + 'softDelete' => static::usesSoftDelete() && config()->boolean('scout.soft_delete'), ]); } @@ -260,7 +260,7 @@ public static function makeAllSearchable(?int $chunk = null): void public static function makeAllSearchableQuery(): EloquentBuilder { $self = new static; - $softDelete = static::usesSoftDelete() && static::getScoutConfig('soft_delete', false); + $softDelete = static::usesSoftDelete() && config()->boolean('scout.soft_delete'); return $self->newQuery() ->when(true, fn ($query) => $self->makeAllSearchableUsing($query)) @@ -430,7 +430,7 @@ public static function withoutSyncingToSearch(callable $callback): mixed */ public function searchableAs(): string { - return static::getScoutConfig('prefix', '') . $this->getTable(); + return config()->string('scout.prefix') . $this->getTable(); } /** @@ -462,7 +462,7 @@ public function searchableUsing(): Engine */ public function syncWithSearchUsing(): ?string { - return static::getScoutConfig('queue.connection'); + return config('scout.queue.connection'); } /** @@ -470,7 +470,7 @@ public function syncWithSearchUsing(): ?string */ public function syncWithSearchUsingQueue(): ?string { - return static::getScoutConfig('queue.queue'); + return config('scout.queue.queue'); } /** @@ -547,7 +547,7 @@ protected static function dispatchSearchableJob(callable $job): void if (! $runner instanceof ConcurrentImportRunner) { $runner = new ConcurrentImportRunner( - (int) static::getScoutConfig('command_concurrency', 50) + config()->integer('scout.command_concurrency') ); CoroutineContext::set(self::SCOUT_RUNNER_CONTEXT_KEY, $runner); } @@ -586,14 +586,4 @@ protected static function usesSoftDelete(): bool { return in_array(SoftDeletes::class, class_uses_recursive(static::class), true); } - - /** - * Get a Scout configuration value. - */ - protected static function getScoutConfig(string $key, mixed $default = null): mixed - { - return Container::getInstance() - ->make('config') - ->get("scout.{$key}", $default); - } } diff --git a/src/scout/src/SearchableScope.php b/src/scout/src/SearchableScope.php index bbbc52dd6..e51d6db44 100644 --- a/src/scout/src/SearchableScope.php +++ b/src/scout/src/SearchableScope.php @@ -37,7 +37,7 @@ public function extend(EloquentBuilder $builder): void /** @var Model&SearchableInterface $model */ $model = $builder->getModel(); $scoutKeyName = $model->getScoutKeyName(); - $chunkSize = $chunk ?? config('scout.chunk.searchable', 500); + $chunkSize = $chunk ?? config()->integer('scout.chunk.searchable'); $builder->chunkById($chunkSize, function (Collection $models) { /** @var EloquentCollection $models */ @@ -59,7 +59,7 @@ public function extend(EloquentBuilder $builder): void /** @var Model&SearchableInterface $model */ $model = $builder->getModel(); $scoutKeyName = $model->getScoutKeyName(); - $chunkSize = $chunk ?? config('scout.chunk.unsearchable', 500); + $chunkSize = $chunk ?? config()->integer('scout.chunk.unsearchable'); $builder->chunkById($chunkSize, function (Collection $models) { /** @var EloquentCollection $models */ diff --git a/tests/Scout/Unit/ConfigFileTest.php b/tests/Scout/Unit/ConfigFileTest.php index 03a4ccf3a..c7a056878 100644 --- a/tests/Scout/Unit/ConfigFileTest.php +++ b/tests/Scout/Unit/ConfigFileTest.php @@ -32,6 +32,12 @@ public function testAlgoliaDefaultsArePresentInConfigFile(): void $this->assertIsArray($config['algolia']); $this->assertArrayHasKey('id', $config['algolia']); $this->assertArrayHasKey('secret', $config['algolia']); + $this->assertArrayHasKey('connect_timeout', $config['algolia']); + $this->assertNull($config['algolia']['connect_timeout']); + $this->assertArrayHasKey('read_timeout', $config['algolia']); + $this->assertNull($config['algolia']['read_timeout']); + $this->assertArrayHasKey('write_timeout', $config['algolia']); + $this->assertNull($config['algolia']['write_timeout']); $this->assertArrayHasKey('index-settings', $config['algolia']); $this->assertIsArray($config['algolia']['index-settings']); } @@ -130,6 +136,44 @@ public function testMeilisearchRetryEnvironmentValuesAreLoadedAsIntegers(): void } } + public function testCommandConcurrencyEnvironmentValueIsLoadedAsAnInteger(): void + { + $environmentKey = 'SCOUT_COMMAND_CONCURRENCY'; + $originalPutenv = getenv($environmentKey); + $originalServerExists = array_key_exists($environmentKey, $_SERVER); + $originalServer = $_SERVER[$environmentKey] ?? null; + $originalEnvExists = array_key_exists($environmentKey, $_ENV); + $originalEnv = $_ENV[$environmentKey] ?? null; + + try { + unset($_SERVER[$environmentKey], $_ENV[$environmentKey]); + putenv("{$environmentKey}=75"); + Env::flushRepository(); + + $config = require dirname(__DIR__, 3) . '/src/scout/config/scout.php'; + + $this->assertSame(75, $config['command_concurrency']); + } finally { + $originalPutenv === false + ? putenv($environmentKey) + : putenv("{$environmentKey}={$originalPutenv}"); + + if ($originalServerExists) { + $_SERVER[$environmentKey] = $originalServer; + } else { + unset($_SERVER[$environmentKey]); + } + + if ($originalEnvExists) { + $_ENV[$environmentKey] = $originalEnv; + } else { + unset($_ENV[$environmentKey]); + } + + Env::flushRepository(); + } + } + #[DataProvider('booleanEnvironmentValues')] public function testBooleanEnvironmentValuesAreLoadedAsBooleans(string $environmentKey, string $configKey): void { @@ -146,7 +190,7 @@ public function testBooleanEnvironmentValuesAreLoadedAsBooleans(string $environm $config = require dirname(__DIR__, 3) . '/src/scout/config/scout.php'; - $this->assertTrue($config[$configKey]); + $this->assertTrue(data_get($config, $configKey)); } finally { $originalPutenv === false ? putenv($environmentKey) @@ -176,6 +220,7 @@ public function testBooleanEnvironmentValuesAreLoadedAsBooleans(string $environm public static function booleanEnvironmentValues(): array { return [ + 'queue' => ['SCOUT_QUEUE', 'queue.enabled'], 'soft deletes' => ['SCOUT_SOFT_DELETE', 'soft_delete'], 'after commit' => ['SCOUT_AFTER_COMMIT', 'after_commit'], ]; diff --git a/tests/Scout/Unit/Console/DeleteAllIndexesCommandTest.php b/tests/Scout/Unit/Console/DeleteAllIndexesCommandTest.php index 560a8133f..97f7873a9 100644 --- a/tests/Scout/Unit/Console/DeleteAllIndexesCommandTest.php +++ b/tests/Scout/Unit/Console/DeleteAllIndexesCommandTest.php @@ -53,7 +53,7 @@ public function testFailsWhenEngineDoesNotSupportDeleteAllIndexes(): void ->once() ->andReturn('collection'); - // Must set a non-empty prefix: the safety gate runs BEFORE engine + // Must set a non-empty prefix: the safety gate runs before engine // resolution, and with an empty prefix we'd hit the refusal message // rather than the "does not support" path. $config = $this->configWithPrefix('test_'); @@ -198,7 +198,7 @@ protected function configWithPrefix(string $prefix): Repository { $config = m::mock(Repository::class); $config->shouldReceive('string') - ->with('scout.prefix', '') + ->with('scout.prefix') ->andReturn($prefix); return $config; diff --git a/tests/Scout/Unit/Console/IndexCommandTest.php b/tests/Scout/Unit/Console/IndexCommandTest.php index 2fbd3ab7a..f7d961206 100644 --- a/tests/Scout/Unit/Console/IndexCommandTest.php +++ b/tests/Scout/Unit/Console/IndexCommandTest.php @@ -29,7 +29,7 @@ public function testZeroPrimaryKeyReachesTheEngine(): void $manager->shouldReceive('engine')->once()->andReturn($engine); $config = m::mock(Repository::class); - $config->shouldReceive('string')->with('scout.prefix', '')->andReturn('prod_'); + $config->shouldReceive('string')->with('scout.prefix')->andReturn('prod_'); $command = $this->command('posts', '0'); $command->shouldReceive('info')->once()->with('Synchronized index ["prod_posts"] successfully.'); @@ -52,7 +52,7 @@ public function testUnsupportedCreationStillAppliesLogicalIndexSettings(): void $manager->shouldReceive('engine')->once()->andReturn($engine); $config = m::mock(Repository::class); - $config->shouldReceive('string')->with('scout.prefix', '')->andReturn('prod_'); + $config->shouldReceive('string')->with('scout.prefix')->andReturn('prod_'); $config->shouldReceive('string')->with('scout.driver')->andReturn('meilisearch'); $config->shouldReceive('get') ->with('scout.meilisearch.index-settings.posts') @@ -76,7 +76,7 @@ public function testPhysicalIndexSettingsAreUsedAsFallback(): void $manager->shouldReceive('engine')->once()->andReturn($engine); $config = m::mock(Repository::class); - $config->shouldReceive('string')->with('scout.prefix', '')->andReturn('prod_'); + $config->shouldReceive('string')->with('scout.prefix')->andReturn('prod_'); $config->shouldReceive('string')->with('scout.driver')->andReturn('meilisearch'); $config->shouldReceive('get')->with('scout.meilisearch.index-settings.posts')->andReturn(null); $config->shouldReceive('get') @@ -99,7 +99,7 @@ public function testLifecycleCallbackCanPrepareAnEmptyNamedIndexEntry(): void $manager = m::mock(EngineManager::class); $manager->shouldReceive('engine')->once()->andReturn($engine); $config = m::mock(Repository::class); - $config->shouldReceive('string')->with('scout.prefix', '')->andReturn('prod_'); + $config->shouldReceive('string')->with('scout.prefix')->andReturn('prod_'); $config->shouldReceive('string')->with('scout.driver')->andReturn('meilisearch'); $config->shouldReceive('get')->with('scout.meilisearch.index-settings.posts')->andReturn(null); $config->shouldReceive('get')->with('scout.meilisearch.index-settings.prod_posts')->andReturn(null); @@ -134,7 +134,7 @@ public function testOperationalCreationFailuresPropagate(): void $manager->shouldReceive('engine')->once()->andReturn($engine); $config = m::mock(Repository::class); - $config->shouldReceive('string')->with('scout.prefix', '')->andReturn(''); + $config->shouldReceive('string')->with('scout.prefix')->andReturn(''); $command = $this->command('posts'); $command->shouldNotReceive('info'); diff --git a/tests/Scout/Unit/Console/SyncIndexSettingsCommandTest.php b/tests/Scout/Unit/Console/SyncIndexSettingsCommandTest.php index c42b7eb96..ad7013b1e 100644 --- a/tests/Scout/Unit/Console/SyncIndexSettingsCommandTest.php +++ b/tests/Scout/Unit/Console/SyncIndexSettingsCommandTest.php @@ -101,7 +101,7 @@ public function testSyncsIndexSettingsSuccessfully(): void 'test_posts' => ['filterableAttributes' => ['status']], ]); $config->shouldReceive('string') - ->with('scout.prefix', '') + ->with('scout.prefix') ->andReturn(''); $command = m::mock(SyncIndexSettingsCommand::class)->makePartial(); @@ -142,7 +142,7 @@ public function testLifecycleCallbackReceivesModelSettingsAfterSoftDeleteContrib ->andReturn([ SyncIndexSettingsSoftDeleteModel::class => ['searchableAttributes' => ['title']], ]); - $config->shouldReceive('boolean')->with('scout.soft_delete', false)->andReturn(true); + $config->shouldReceive('boolean')->with('scout.soft_delete')->andReturn(true); Scout::prepareIndexSettingsUsing(function ( array $settings, @@ -181,7 +181,7 @@ public function testUsesDriverOptionWhenProvided(): void ->andReturn($engine); $config = m::mock(Repository::class); - // Note: scout.driver should NOT be called when driver option is provided + // Note: scout.driver should not be called when driver option is provided $config->shouldReceive('array') ->with('scout.typesense.index-settings', []) ->andReturn([]); @@ -263,7 +263,7 @@ public function testIndexNameResolutionPrependsPrefix(): void $config = m::mock(Repository::class); $config->shouldReceive('string') - ->with('scout.prefix', '') + ->with('scout.prefix') ->andReturn('prod_'); // Test that prefix is prepended when not already present @@ -280,10 +280,10 @@ public function testIndexNameResolutionDoesNotDuplicatePrefix(): void $config = m::mock(Repository::class); $config->shouldReceive('string') - ->with('scout.prefix', '') + ->with('scout.prefix') ->andReturn('prod_'); - // Test that prefix is NOT duplicated when already present + // Test that prefix is not duplicated when already present $result = $method->invoke($command, 'prod_posts', $config); $this->assertSame('prod_posts', $result); } diff --git a/tests/Scout/Unit/ScoutServiceProviderTest.php b/tests/Scout/Unit/ScoutServiceProviderTest.php index f8fb44887..1a86d49b9 100644 --- a/tests/Scout/Unit/ScoutServiceProviderTest.php +++ b/tests/Scout/Unit/ScoutServiceProviderTest.php @@ -18,9 +18,11 @@ use Hypervel\Scout\ScoutServiceProvider; use Hypervel\Support\ClassInvoker; use Hypervel\Testbench\TestCase; +use InvalidArgumentException; use Meilisearch\Client as MeilisearchClient; use Meilisearch\Http\Client as MeilisearchHttpClient; use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; use Psr\Http\Client\ClientInterface; use ReflectionProperty; use stdClass; @@ -116,6 +118,39 @@ public function testAlgoliaClientIsRegistered(): void $this->assertInstanceOf(AlgoliaSearchClient::class, $client); } + public function testNullAlgoliaTimeoutsPreserveSdkDefaults(): void + { + $this->app->make('config')->set('scout.algolia.id', 'test-app-id'); + $this->app->make('config')->set('scout.algolia.secret', 'test-secret'); + $this->app->forgetInstance(AlgoliaSearchClient::class); + + $client = $this->app->make(AlgoliaSearchClient::class); + $configuration = (new ClassInvoker($client))->config; + + $this->assertSame(2, $configuration->getConnectTimeout()); + $this->assertSame(5, $configuration->getReadTimeout()); + $this->assertSame(30, $configuration->getWriteTimeout()); + } + + public function testAlgoliaTimeoutsOverrideSdkDefaults(): void + { + $this->app->make('config')->set([ + 'scout.algolia.id' => 'test-app-id', + 'scout.algolia.secret' => 'test-secret', + 'scout.algolia.connect_timeout' => 11, + 'scout.algolia.read_timeout' => 12, + 'scout.algolia.write_timeout' => 13, + ]); + $this->app->forgetInstance(AlgoliaSearchClient::class); + + $client = $this->app->make(AlgoliaSearchClient::class); + $configuration = (new ClassInvoker($client))->config; + + $this->assertSame(11, $configuration->getConnectTimeout()); + $this->assertSame(12, $configuration->getReadTimeout()); + $this->assertSame(13, $configuration->getWriteTimeout()); + } + public function testAlgoliaSdkUsesExplicitGuzzleAfterProviderBoot(): void { $wrapper = Algolia::getHttpClient(); @@ -250,6 +285,34 @@ public function testMeilisearchClientOmitsRetryMiddlewareWhenRetriesDisabled(): $this->assertSame(1, $mock->count(), 'only the first response should be consumed when retries are disabled'); } + #[DataProvider('requiredMeilisearchRetrySettings')] + public function testMeilisearchRetrySettingsAreRequired(string $member): void + { + $config = $this->app->make('config'); + $meilisearch = $config->array('scout.meilisearch'); + unset($meilisearch[$member]); + $config->set('scout.meilisearch', $meilisearch); + $this->app->forgetInstance(MeilisearchClient::class); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Configuration value for key [scout.meilisearch.{$member}] must be an integer"); + + $this->app->make(MeilisearchClient::class); + } + + /** + * Provide required Meilisearch retry settings. + * + * @return array + */ + public static function requiredMeilisearchRetrySettings(): array + { + return [ + 'retry count' => ['retries'], + 'initial retry delay' => ['initial_retry_delay_ms'], + ]; + } + public function testTypesenseClientHasScoutTelescopeTags(): void { $this->app->make('config')->set('scout.typesense.client-settings', [ From 2d7a87a50608c5d19544e5531891313f4f74f151 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:43:00 +0000 Subject: [PATCH 024/109] sentry: centralize typed configuration and capability checks Replace the hardcoded SdkCapabilities reader with a root-aware SentryConfig service used consistently by the provider, features, aspects, tracing, publishing, and runtime reloads. Reject competing providers before mutation so custom aliases cannot mix configuration roots or duplicate telemetry registration. Normalize every boolean env declaration and the SQL-origin threshold, add the missing storage feature flags, align Spotlight's string zero state with the SDK, remove the renamed log-level compatibility alias, and require complete internal feature and handler records while retaining open SDK option bags. Expand config, alias, telemetry, coroutine, event, storage, cache, tracing, and provider coverage, including numeric and boolean env regressions, active-endpoint behavior, cache-event coupling, and complete shipped fixtures; update the Sentry guide for the current env and custom-provider contracts. --- src/docs/sentry.md | 28 ++++- src/sentry/config/sentry.php | 70 +++++++----- .../src/Aspects/GuzzleHttpClientAspect.php | 19 ++- src/sentry/src/EventHandler.php | 10 +- src/sentry/src/Features/CacheFeature.php | 4 +- src/sentry/src/Features/Feature.php | 11 +- .../src/Http/HypervelRequestFetcher.php | 2 +- .../{SdkCapabilities.php => SentryConfig.php} | 29 +++-- src/sentry/src/SentryServiceProvider.php | 50 +++++--- src/sentry/src/Tracing/EventHandler.php | 8 +- src/sentry/src/Tracing/Middleware.php | 3 +- .../Aspects/GuzzleHttpClientAspectTest.php | 30 +++-- tests/Sentry/ConfigTest.php | 108 ++++++++++++++++++ tests/Sentry/CoroutineSafetyTest.php | 5 +- .../EventHandler/DatabaseEventsTest.php | 8 +- tests/Sentry/EventHandler/LogEventsTest.php | 4 +- tests/Sentry/EventHandlerTest.php | 6 +- .../Sentry/Features/CacheIntegrationTest.php | 2 +- .../Features/ConsoleIntegrationTest.php | 4 +- .../Features/DatabaseIntegrationTest.php | 46 +++++--- .../Features/NotificationsIntegrationTest.php | 20 ++-- .../Sentry/Features/RedisIntegrationTest.php | 35 +++--- .../Features/StorageIntegrationTest.php | 23 ++++ tests/Sentry/FlushLifecycleTest.php | 13 +-- tests/Sentry/SentryTestCase.php | 19 +++ ...erviceProviderListenerRegistrationTest.php | 26 +++-- tests/Sentry/ServiceProviderTest.php | 27 ++++- .../ServiceProviderWithCustomAliasTest.php | 9 +- tests/Sentry/Tracing/EventHandlerTest.php | 20 ++-- tests/Sentry/Tracing/MiddlewareTest.php | 3 +- 30 files changed, 449 insertions(+), 193 deletions(-) rename src/sentry/src/{SdkCapabilities.php => SentryConfig.php} (76%) diff --git a/src/docs/sentry.md b/src/docs/sentry.md index 7e52aa9e6..5388469f5 100644 --- a/src/docs/sentry.md +++ b/src/docs/sentry.md @@ -3,6 +3,7 @@ - [Introduction](#introduction) - [Installation](#installation) - [Configuration](#configuration) + - [Replacing the Service Provider](#replacing-the-service-provider) - [Testing Your Installation](#testing-your-installation) - [Reporting Exceptions](#reporting-exceptions) - [Logging](#logging) @@ -65,6 +66,22 @@ SENTRY_HYPERVEL_DSN=https://examplePublicKey@o0.ingest.sentry.io/0 You may leave the DSN unset to disable event delivery while keeping the package installed, unless Spotlight is enabled. + +### Replacing the Service Provider + +You may extend `SentryServiceProvider` when your application needs to bind Sentry under a different container and configuration key: + +```php +use Hypervel\Sentry\SentryServiceProvider as BaseSentryServiceProvider; + +class SentryServiceProvider extends BaseSentryServiceProvider +{ + public static string $abstract = 'custom-sentry'; +} +``` + +Add `hypervel/sentry` to `extra.hypervel.dont-discover` in your application's `composer.json`, then register the custom provider in `bootstrap/providers.php`. The custom provider replaces the discovered provider; registering both is not supported because they would share one Sentry SDK hub while reading different configuration roots. + ### Testing Your Installation @@ -139,7 +156,7 @@ Log::channel('sentry_logs')->info('Order shipped', [ ]); ``` -The channel uses `SENTRY_LOG_LEVEL`, falling back to `SENTRY_LOGS_LEVEL` and then your application's `LOG_LEVEL` value. +The channel uses `SENTRY_LOG_LEVEL` and falls back directly to your application's `LOG_LEVEL` value. The upstream `SENTRY_LOGS_LEVEL` compatibility alias is not supported. ## Performance Monitoring @@ -152,6 +169,8 @@ SENTRY_TRACES_SAMPLE_RATE=0.1 The default configuration traces requests, database queries, HTTP client requests, cache operations, queued jobs, notifications, and views. The conventional `/up` health route path is ignored by default. +Enabling cache spans or breadcrumbs also enables repository events for every configured cache store while Sentry has an active DSN or Spotlight endpoint. This applies even when a store's own `events` option is `false`, because those events are required to record cache telemetry. Disable both `SENTRY_TRACE_CACHE_ENABLED` and `SENTRY_BREADCRUMBS_CACHE_ENABLED` when cache repository events must remain disabled. + Incoming trace headers are still propagated when local trace recording is disabled. This allows a Hypervel service to remain part of a distributed trace without recording its own transaction. By default, request transactions include work performed after the response is sent. You may finish transactions during the HTTP terminate phase instead: @@ -275,6 +294,13 @@ To configure a single disk, pass its name and configuration: Both methods accept `enableSpans` and `enableBreadcrumbs` arguments. Per-disk settings cannot enable telemetry that is disabled globally. +Filesystem spans and breadcrumbs are enabled globally by default. You may disable either form of telemetry using `SENTRY_TRACE_STORAGE_ENABLED` or `SENTRY_BREADCRUMBS_STORAGE_ENABLED`: + +```ini +SENTRY_TRACE_STORAGE_ENABLED=false +SENTRY_BREADCRUMBS_STORAGE_ENABLED=false +``` + The integration preserves filesystem pooling, scoped prefixes, temporary URLs, streaming behavior, and fluent filesystem operations. diff --git a/src/sentry/config/sentry.php b/src/sentry/config/sentry.php index b048b582a..8369c0097 100644 --- a/src/sentry/config/sentry.php +++ b/src/sentry/config/sentry.php @@ -24,6 +24,8 @@ // @see https://docs.sentry.io/concepts/key-terms/dsn-explainer/ 'dsn' => env('SENTRY_HYPERVEL_DSN', env('SENTRY_DSN')), + // Set to true to use the default Spotlight endpoint, a URL string to use + // a custom endpoint, or false to disable Spotlight. // @see https://spotlightjs.com/ 'spotlight' => env('SENTRY_SPOTLIGHT', false), @@ -50,22 +52,22 @@ 'profiles_sample_rate' => env('SENTRY_PROFILES_SAMPLE_RATE') === null ? null : (float) env('SENTRY_PROFILES_SAMPLE_RATE'), // Only continue incoming traces when the organization IDs are compatible with this SDK instance. - 'strict_trace_continuation' => env('SENTRY_STRICT_TRACE_CONTINUATION', false), + 'strict_trace_continuation' => (bool) env('SENTRY_STRICT_TRACE_CONTINUATION', false), // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#enable_logs - 'enable_logs' => env('SENTRY_ENABLE_LOGS', false), + 'enable_logs' => (bool) env('SENTRY_ENABLE_LOGS', false), // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#enable_metrics - 'enable_metrics' => env('SENTRY_ENABLE_METRICS', true), + 'enable_metrics' => (bool) env('SENTRY_ENABLE_METRICS', true), // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#log_flush_threshold 'log_flush_threshold' => env('SENTRY_LOG_FLUSH_THRESHOLD') === null ? null : (int) env('SENTRY_LOG_FLUSH_THRESHOLD'), // The minimum log level that will be sent to Sentry as logs using the `sentry_logs` logging channel - 'logs_channel_level' => env('SENTRY_LOG_LEVEL', env('SENTRY_LOGS_LEVEL', env('LOG_LEVEL', 'debug'))), + 'logs_channel_level' => env('SENTRY_LOG_LEVEL', env('LOG_LEVEL', 'debug')), // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#send_default_pii - 'send_default_pii' => env('SENTRY_SEND_DEFAULT_PII', false), + 'send_default_pii' => (bool) env('SENTRY_SEND_DEFAULT_PII', false), // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore_exceptions // 'ignore_exceptions' => [], @@ -79,76 +81,84 @@ // Breadcrumb specific configuration 'breadcrumbs' => [ // Capture log messages as breadcrumbs - 'logs' => env('SENTRY_BREADCRUMBS_LOGS_ENABLED', true), + 'logs' => (bool) env('SENTRY_BREADCRUMBS_LOGS_ENABLED', true), - // Capture cache events (hits, writes etc.) as breadcrumbs - 'cache' => env('SENTRY_BREADCRUMBS_CACHE_ENABLED', true), + // Capture cache events (hits, writes etc.) as breadcrumbs. + // When Sentry is active, this enables repository events for every configured cache store. + 'cache' => (bool) env('SENTRY_BREADCRUMBS_CACHE_ENABLED', true), + + // Capture filesystem operations as breadcrumbs + 'storage' => (bool) env('SENTRY_BREADCRUMBS_STORAGE_ENABLED', true), // Capture SQL queries as breadcrumbs - 'sql_queries' => env('SENTRY_BREADCRUMBS_SQL_QUERIES_ENABLED', true), + 'sql_queries' => (bool) env('SENTRY_BREADCRUMBS_SQL_QUERIES_ENABLED', true), // Capture SQL query bindings (parameters) in SQL query breadcrumbs - 'sql_bindings' => env('SENTRY_BREADCRUMBS_SQL_BINDINGS_ENABLED', false), + 'sql_bindings' => (bool) env('SENTRY_BREADCRUMBS_SQL_BINDINGS_ENABLED', false), // Capture SQL transactions (begin, commit, rollbacks) as breadcrumbs - 'sql_transactions' => env('SENTRY_BREADCRUMBS_SQL_TRANSACTIONS_ENABLED', true), + 'sql_transactions' => (bool) env('SENTRY_BREADCRUMBS_SQL_TRANSACTIONS_ENABLED', true), // Capture queue job information as breadcrumbs - 'queue_info' => env('SENTRY_BREADCRUMBS_QUEUE_INFO_ENABLED', true), + 'queue_info' => (bool) env('SENTRY_BREADCRUMBS_QUEUE_INFO_ENABLED', true), // Capture command information as breadcrumbs - 'command_info' => env('SENTRY_BREADCRUMBS_COMMAND_JOBS_ENABLED', true), + 'command_info' => (bool) env('SENTRY_BREADCRUMBS_COMMAND_JOBS_ENABLED', true), // Capture HTTP client request information as breadcrumbs - 'http_client_requests' => env('SENTRY_BREADCRUMBS_HTTP_CLIENT_REQUESTS_ENABLED', true), + 'http_client_requests' => (bool) env('SENTRY_BREADCRUMBS_HTTP_CLIENT_REQUESTS_ENABLED', true), // Capture send notifications as breadcrumbs - 'notifications' => env('SENTRY_BREADCRUMBS_NOTIFICATIONS_ENABLED', true), + 'notifications' => (bool) env('SENTRY_BREADCRUMBS_NOTIFICATIONS_ENABLED', true), ], // Performance monitoring specific configuration 'tracing' => [ // Trace queue jobs as their own transactions (this enables tracing for queue jobs) - 'queue_job_transactions' => env('SENTRY_TRACE_QUEUE_ENABLED', true), + 'queue_job_transactions' => (bool) env('SENTRY_TRACE_QUEUE_ENABLED', true), // Capture queue jobs as spans when executed on the sync driver - 'queue_jobs' => env('SENTRY_TRACE_QUEUE_JOBS_ENABLED', true), + 'queue_jobs' => (bool) env('SENTRY_TRACE_QUEUE_JOBS_ENABLED', true), // Capture SQL queries as spans - 'sql_queries' => env('SENTRY_TRACE_SQL_QUERIES_ENABLED', true), + 'sql_queries' => (bool) env('SENTRY_TRACE_SQL_QUERIES_ENABLED', true), // Capture SQL query bindings (parameters) in SQL query spans - 'sql_bindings' => env('SENTRY_TRACE_SQL_BINDINGS_ENABLED', false), + 'sql_bindings' => (bool) env('SENTRY_TRACE_SQL_BINDINGS_ENABLED', false), // Capture where the SQL query originated from on the SQL query spans - 'sql_origin' => env('SENTRY_TRACE_SQL_ORIGIN_ENABLED', true), + 'sql_origin' => (bool) env('SENTRY_TRACE_SQL_ORIGIN_ENABLED', true), // Define a threshold in milliseconds for SQL queries to resolve their origin - 'sql_origin_threshold_ms' => env('SENTRY_TRACE_SQL_ORIGIN_THRESHOLD_MS', 100), + 'sql_origin_threshold_ms' => (int) env('SENTRY_TRACE_SQL_ORIGIN_THRESHOLD_MS', 100), // Capture views rendered as spans - 'views' => env('SENTRY_TRACE_VIEWS_ENABLED', true), + 'views' => (bool) env('SENTRY_TRACE_VIEWS_ENABLED', true), // Capture HTTP client requests as spans - 'http_client_requests' => env('SENTRY_TRACE_HTTP_CLIENT_REQUESTS_ENABLED', true), + 'http_client_requests' => (bool) env('SENTRY_TRACE_HTTP_CLIENT_REQUESTS_ENABLED', true), + + // Capture cache events (hits, writes etc.) as spans. + // When Sentry is active, this enables repository events for every configured cache store. + 'cache' => (bool) env('SENTRY_TRACE_CACHE_ENABLED', true), - // Capture cache events (hits, writes etc.) as spans - 'cache' => env('SENTRY_TRACE_CACHE_ENABLED', true), + // Capture filesystem operations as spans + 'storage' => (bool) env('SENTRY_TRACE_STORAGE_ENABLED', true), // Capture Redis operations as spans (this enables Redis events in Hypervel) - 'redis_commands' => env('SENTRY_TRACE_REDIS_COMMANDS', false), + 'redis_commands' => (bool) env('SENTRY_TRACE_REDIS_COMMANDS', false), // Capture where the Redis command originated from on the Redis command spans - 'redis_origin' => env('SENTRY_TRACE_REDIS_ORIGIN_ENABLED', true), + 'redis_origin' => (bool) env('SENTRY_TRACE_REDIS_ORIGIN_ENABLED', true), // Capture send notifications as spans - 'notifications' => env('SENTRY_TRACE_NOTIFICATIONS_ENABLED', true), + 'notifications' => (bool) env('SENTRY_TRACE_NOTIFICATIONS_ENABLED', true), // Enable tracing for requests without a matching route (404's) - 'missing_routes' => env('SENTRY_TRACE_MISSING_ROUTES_ENABLED', false), + 'missing_routes' => (bool) env('SENTRY_TRACE_MISSING_ROUTES_ENABLED', false), // Continue the trace through after-response work before finishing the transaction - 'continue_after_response' => env('SENTRY_TRACE_CONTINUE_AFTER_RESPONSE', true), + 'continue_after_response' => (bool) env('SENTRY_TRACE_CONTINUE_AFTER_RESPONSE', true), ], /* diff --git a/src/sentry/src/Aspects/GuzzleHttpClientAspect.php b/src/sentry/src/Aspects/GuzzleHttpClientAspect.php index 25e7943cf..8abd8b1d6 100644 --- a/src/sentry/src/Aspects/GuzzleHttpClientAspect.php +++ b/src/sentry/src/Aspects/GuzzleHttpClientAspect.php @@ -6,11 +6,10 @@ use GuzzleHttp\Client; use GuzzleHttp\TransferStats; -use Hypervel\Contracts\Config\Repository; use Hypervel\Di\Aop\AbstractAspect; use Hypervel\Di\Aop\ProceedingJoinPoint; use Hypervel\Sentry\Integration; -use Hypervel\Sentry\SdkCapabilities; +use Hypervel\Sentry\SentryConfig; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\UriInterface; use Sentry\Breadcrumb; @@ -49,14 +48,14 @@ class GuzzleHttpClientAspect extends AbstractAspect /** * Create a new aspect instance. */ - public function __construct( - private readonly Repository $config, - SdkCapabilities $capabilities, - ) { - $this->tracingEnabled = $capabilities->canRecordSpans() - && $this->config->boolean('sentry.tracing.http_client_requests', true); - $this->breadcrumbsEnabled = $capabilities->canRecordBreadcrumbs() - && $this->config->boolean('sentry.breadcrumbs.http_client_requests', true); + public function __construct(SentryConfig $config) + { + $userConfig = $config->all(); + + $this->tracingEnabled = $config->canRecordSpans() + && $userConfig['tracing']['http_client_requests'] === true; + $this->breadcrumbsEnabled = $config->canRecordBreadcrumbs() + && $userConfig['breadcrumbs']['http_client_requests'] === true; } /** diff --git a/src/sentry/src/EventHandler.php b/src/sentry/src/EventHandler.php index 8beb9278d..50eb0384e 100644 --- a/src/sentry/src/EventHandler.php +++ b/src/sentry/src/EventHandler.php @@ -64,10 +64,12 @@ public function __construct( private readonly Container $container, array $config, ) { - $this->recordSqlQueries = ($config['breadcrumbs']['sql_queries'] ?? true) === true; - $this->recordSqlBindings = ($config['breadcrumbs']['sql_bindings'] ?? false) === true; - $this->recordSqlTransactions = ($config['breadcrumbs']['sql_transactions'] ?? true) === true; - $this->recordLogs = ($config['breadcrumbs']['logs'] ?? true) === true; + $breadcrumbs = $config['breadcrumbs']; + + $this->recordSqlQueries = $breadcrumbs['sql_queries'] === true; + $this->recordSqlBindings = $breadcrumbs['sql_bindings'] === true; + $this->recordSqlTransactions = $breadcrumbs['sql_transactions'] === true; + $this->recordLogs = $breadcrumbs['logs'] === true; } /** diff --git a/src/sentry/src/Features/CacheFeature.php b/src/sentry/src/Features/CacheFeature.php index c6fc9cfb3..2f17ad570 100644 --- a/src/sentry/src/Features/CacheFeature.php +++ b/src/sentry/src/Features/CacheFeature.php @@ -56,7 +56,9 @@ public function isApplicable(): bool public function onBoot(): void { $config = $this->container->make('config'); - $stores = array_keys($config->array('cache.stores', [])); + $stores = array_keys($config->array('cache.stores')); + // This method runs only for an active endpoint with cache telemetry enabled, + // which requires repository events even when a store explicitly disabled them. foreach ($stores as $store) { $config->set("cache.stores.{$store}.events", true); } diff --git a/src/sentry/src/Features/Feature.php b/src/sentry/src/Features/Feature.php index e5e35c944..7bbdb05ef 100644 --- a/src/sentry/src/Features/Feature.php +++ b/src/sentry/src/Features/Feature.php @@ -5,12 +5,9 @@ namespace Hypervel\Sentry\Features; use Hypervel\Contracts\Container\Container; -use Hypervel\Sentry\SdkCapabilities; +use Hypervel\Sentry\SentryConfig; use Sentry\SentrySdk; -/** - * @internal - */ abstract class Feature { /** @@ -92,7 +89,7 @@ public function bootInactive(): void */ protected function getUserConfig(): array { - return $this->container->make('config')->array('sentry', []); + return $this->container->make(SentryConfig::class)->all(); } /** @@ -155,7 +152,7 @@ protected function canRecordSpans(): bool } return $this->canRecordSpans = $this->container - ->make(SdkCapabilities::class) + ->make(SentryConfig::class) ->canRecordSpans(); } @@ -169,7 +166,7 @@ protected function canRecordBreadcrumbs(): bool } return $this->canRecordBreadcrumbs = $this->container - ->make(SdkCapabilities::class) + ->make(SentryConfig::class) ->canRecordBreadcrumbs(); } } diff --git a/src/sentry/src/Http/HypervelRequestFetcher.php b/src/sentry/src/Http/HypervelRequestFetcher.php index 5d19bc09a..6ff8e8609 100644 --- a/src/sentry/src/Http/HypervelRequestFetcher.php +++ b/src/sentry/src/Http/HypervelRequestFetcher.php @@ -43,7 +43,7 @@ public function fetchRequest(): ?ServerRequestInterface */ protected function filterCookies(array $cookies): array { - $forbiddenCookies = [config('session.cookie'), 'remember_*', 'XSRF-TOKEN']; + $forbiddenCookies = [config()->string('session.cookie'), 'remember_*', 'XSRF-TOKEN']; $filtered = []; foreach ($cookies as $key => $value) { diff --git a/src/sentry/src/SdkCapabilities.php b/src/sentry/src/SentryConfig.php similarity index 76% rename from src/sentry/src/SdkCapabilities.php rename to src/sentry/src/SentryConfig.php index d81056e1b..be0ae18a3 100644 --- a/src/sentry/src/SdkCapabilities.php +++ b/src/sentry/src/SentryConfig.php @@ -10,13 +10,14 @@ /** * @internal */ -class SdkCapabilities +class SentryConfig { /** - * Create a new SDK capability reader. + * Create a new Sentry configuration reader. */ public function __construct( private readonly Repository $config, + private readonly string $root, ) { } @@ -25,7 +26,7 @@ public function __construct( */ public function hasDsnSet(): bool { - return self::configHasDsn($this->userConfig()); + return self::configHasDsn($this->all()); } /** @@ -33,7 +34,7 @@ public function hasDsnSet(): bool */ public function hasSpotlightEnabled(): bool { - return self::configHasSpotlightEnabled($this->userConfig()); + return self::configHasSpotlightEnabled($this->all()); } /** @@ -41,13 +42,14 @@ public function hasSpotlightEnabled(): bool */ public function canRecordSpans(): bool { - $config = $this->userConfig(); + $config = $this->all(); $enableTracing = $config['enable_tracing'] ?? null; + $tracesSampleRate = $config['traces_sample_rate']; // Mirror Options::__construct()'s legacy enable_tracing default and Options::isTracingEnabled(). $tracingEnabled = $enableTracing === true || ($enableTracing !== false - && (($config['traces_sample_rate'] ?? null) !== null + && ($tracesSampleRate !== null || ($config['traces_sampler'] ?? null) !== null)); return self::configHasActiveEndpoint($config) && $tracingEnabled; @@ -58,7 +60,7 @@ public function canRecordSpans(): bool */ public function canRecordBreadcrumbs(): bool { - $config = $this->userConfig(); + $config = $this->all(); return self::configHasActiveEndpoint($config) && ($config['max_breadcrumbs'] ?? Options::DEFAULT_MAX_BREADCRUMBS) > 0; @@ -69,9 +71,9 @@ public function canRecordBreadcrumbs(): bool * * @return array */ - private function userConfig(): array + public function all(): array { - return $this->config->array('sentry', []); + return $this->config->array($this->root); } /** @@ -81,7 +83,9 @@ private function userConfig(): array */ private static function configHasDsn(array $config): bool { - return ! empty($config['dsn']); + $dsn = $config['dsn']; + + return ! empty($dsn); } /** @@ -91,9 +95,10 @@ private static function configHasDsn(array $config): bool */ private static function configHasSpotlightEnabled(array $config): bool { - $spotlight = $config['spotlight'] ?? false; + $spotlight = $config['spotlight']; - return $spotlight === true || (is_string($spotlight) && $spotlight !== ''); + // Match the SDK's disabled handling for the environment string '0'. + return $spotlight === true || (is_string($spotlight) && ! empty($spotlight)); } /** diff --git a/src/sentry/src/SentryServiceProvider.php b/src/sentry/src/SentryServiceProvider.php index 5498072bf..6df76fcbe 100644 --- a/src/sentry/src/SentryServiceProvider.php +++ b/src/sentry/src/SentryServiceProvider.php @@ -41,6 +41,7 @@ use Hypervel\View\Engines\EngineResolver; use Hypervel\View\Factory as ViewFactory; use InvalidArgumentException; +use LogicException; use Psr\Log\LoggerInterface; use RuntimeException; use Sentry\ClientBuilder; @@ -119,6 +120,19 @@ public function boot(): void */ public function register(): void { + if ($this->app->bound(SentryConfig::class)) { + throw new LogicException(sprintf( + 'Sentry provider [%s] cannot be registered because another Sentry provider is already registered. Add [hypervel/sentry] to [extra.hypervel.dont-discover] before registering a custom provider, or remove the custom provider.', + static::class, + )); + } + + $configRoot = static::$abstract; + $this->app->singleton( + SentryConfig::class, + fn () => new SentryConfig($this->app->make(ConfigRepository::class), $configRoot), + ); + $this->mergeConfigFrom(__DIR__ . '/../config/sentry.php', static::$abstract); $this->app->singleton(DebugFileLogger::class, function () { @@ -163,8 +177,10 @@ public function reloadConfiguration(): void */ protected function configureAndRegisterClient(): void { + $configRoot = static::$abstract; + // ClientBuilder — fresh per resolution so each Hub gets a properly configured builder - $this->app->bind(ClientBuilder::class, function () { + $this->app->bind(ClientBuilder::class, function () use ($configRoot) { $basePath = base_path(); $userConfig = $this->getUserConfig(); @@ -200,7 +216,7 @@ protected function configureAndRegisterClient(): void $clientBuilder->setSdkVersion(Version::getSdkVersion()); // Set the pooled transport for async sending via Swoole coroutines - $poolConfig = $this->app->make('config')->array('sentry.pool', []); + $poolConfig = $this->app->make('config')->array("{$configRoot}.pool"); $transport = new HttpPoolTransport( new Pool( $clientBuilder->getOptions(), @@ -245,7 +261,7 @@ protected function createClient(): ClientInterface $userConfig = $this->getUserConfig(); /** @var array|callable $userIntegrationOption */ - $userIntegrationOption = $userConfig['integrations'] ?? []; + $userIntegrationOption = $userConfig['integrations']; $userIntegrations = $this->resolveIntegrationsFromUserConfig( is_array($userIntegrationOption) ? $userIntegrationOption : [], @@ -339,11 +355,11 @@ protected function bindEvents(): void $handler->subscribe($dispatcher); - if (isset($userConfig['send_default_pii']) && $userConfig['send_default_pii'] !== false) { + if ($userConfig['send_default_pii'] === true) { $handler->subscribeAuthEvents($dispatcher); } - if (isset($userConfig['enable_logs']) && $userConfig['enable_logs'] === true) { + if ($userConfig['enable_logs'] === true) { $this->app->terminating(static function () { Logs::getInstance()->flush(); }); @@ -378,14 +394,15 @@ protected function registerMiddleware(): void */ protected function bootTracing(): void { - $tracingConfig = $this->getUserConfig()['tracing'] ?? []; + $tracingConfig = $this->getUserConfig()['tracing']; // Register the tracing middleware as scoped so each coroutine gets its own instance. // Per-request state ($transaction, $appSpan, $didRouteMatch) is isolated between concurrent requests. $this->app->scoped( TracingMiddleware::class, static fn () => new TracingMiddleware( - ($tracingConfig['continue_after_response'] ?? true) === true, + $tracingConfig['continue_after_response'] === true, + $tracingConfig['missing_routes'] === true, ), ); @@ -424,7 +441,7 @@ private function bindTracingEvents(array $tracingConfig): void */ private function bindViewEngine(array $tracingConfig): void { - if (($tracingConfig['views'] ?? true) !== true) { + if ($tracingConfig['views'] !== true) { return; } @@ -530,7 +547,7 @@ protected function registerCoroutineContextPropagation(): void */ protected function registerFeatures(): void { - $features = $this->app->make('config')->array('sentry.features', []); + $features = $this->app->make('config')->array(static::$abstract . '.features'); foreach ($features as $feature) { try { @@ -551,7 +568,7 @@ protected function bootFeatures(): void { $bootActive = $this->isActive(); - $features = $this->app->make('config')->array('sentry.features', []); + $features = $this->app->make('config')->array(static::$abstract . '.features'); foreach ($features as $feature) { try { @@ -592,12 +609,13 @@ private function reportFeatureFailure(string $feature, string $phase, Throwable protected function registerLogChannels(): void { $config = $this->app->make(ConfigRepository::class); + $configRoot = static::$abstract; // Derived config can depend on the worker environment, so replay the operation after config reload rather than its master result. $this->app->make(ConfigMutationTracker::class)->applyAndRecord( $config, - static function (ConfigRepository $config): void { - $logChannels = $config->array('logging.channels', []); + static function (ConfigRepository $config) use ($configRoot): void { + $logChannels = $config->array('logging.channels'); if (! array_key_exists('sentry', $logChannels)) { $config->set('logging.channels.sentry', [ @@ -608,7 +626,7 @@ static function (ConfigRepository $config): void { if (! array_key_exists('sentry_logs', $logChannels)) { $config->set('logging.channels.sentry_logs', [ 'driver' => 'sentry_logs', - 'level' => $config->string('sentry.logs_channel_level', 'debug'), + 'level' => $config->string("{$configRoot}.logs_channel_level"), ]); } }, @@ -621,7 +639,7 @@ static function (ConfigRepository $config): void { protected function registerPublishing(): void { $this->publishes([ - __DIR__ . '/../config/sentry.php' => config_path('sentry.php'), + __DIR__ . '/../config/sentry.php' => config_path(static::$abstract . '.php'), ], 'sentry-config'); } @@ -701,7 +719,7 @@ protected function isActive(): bool */ protected function hasDsnSet(): bool { - return $this->app->make(SdkCapabilities::class)->hasDsnSet(); + return $this->app->make(SentryConfig::class)->hasDsnSet(); } /** @@ -709,7 +727,7 @@ protected function hasDsnSet(): bool */ protected function hasSpotlightEnabled(): bool { - return $this->app->make(SdkCapabilities::class)->hasSpotlightEnabled(); + return $this->app->make(SentryConfig::class)->hasSpotlightEnabled(); } /** diff --git a/src/sentry/src/Tracing/EventHandler.php b/src/sentry/src/Tracing/EventHandler.php index c8b7cab2c..67ebae552 100644 --- a/src/sentry/src/Tracing/EventHandler.php +++ b/src/sentry/src/Tracing/EventHandler.php @@ -57,10 +57,10 @@ class EventHandler */ public function __construct(array $config) { - $this->traceSqlQueries = ($config['sql_queries'] ?? true) === true; - $this->traceSqlBindings = ($config['sql_bindings'] ?? false) === true; - $this->traceSqlQueryOrigin = ($config['sql_origin'] ?? true) === true; - $this->traceSqlQueryOriginThresholdMs = $config['sql_origin_threshold_ms'] ?? 100; + $this->traceSqlQueries = $config['sql_queries'] === true; + $this->traceSqlBindings = $config['sql_bindings'] === true; + $this->traceSqlQueryOrigin = $config['sql_origin'] === true; + $this->traceSqlQueryOriginThresholdMs = $config['sql_origin_threshold_ms']; } /** diff --git a/src/sentry/src/Tracing/Middleware.php b/src/sentry/src/Tracing/Middleware.php index 31d4b0c11..d507e2cb7 100644 --- a/src/sentry/src/Tracing/Middleware.php +++ b/src/sentry/src/Tracing/Middleware.php @@ -51,6 +51,7 @@ class Middleware */ public function __construct( private readonly bool $continueAfterResponse = true, + private readonly bool $traceMissingRoutes = false, ) { } @@ -288,7 +289,7 @@ private function internalSignalRouteWasMatched(): void */ private function shouldRouteBeIgnored(): bool { - return ! $this->didRouteMatch && config('sentry.tracing.missing_routes', false) === false; + return ! $this->didRouteMatch && ! $this->traceMissingRoutes; } /** diff --git a/tests/Sentry/Aspects/GuzzleHttpClientAspectTest.php b/tests/Sentry/Aspects/GuzzleHttpClientAspectTest.php index 5629def9f..45d4908ec 100644 --- a/tests/Sentry/Aspects/GuzzleHttpClientAspectTest.php +++ b/tests/Sentry/Aspects/GuzzleHttpClientAspectTest.php @@ -48,7 +48,9 @@ public function testBreadcrumbIsRecorded() public function testBreadcrumbIsNotRecordedWhenDisabled() { $this->resetApplicationWithConfig([ - 'sentry.breadcrumbs.http_client_requests' => false, + 'sentry' => $this->sentryConfigWith([ + 'breadcrumbs.http_client_requests' => false, + ]), ]); $client = $this->makeClient([ @@ -155,8 +157,10 @@ public function testFailedTransferFinishesTheExactHttpSpan(): void public function testSpanIsNotRecordedWhenDisabled() { $this->resetApplicationWithConfig([ - 'sentry.traces_sample_rate' => 1.0, - 'sentry.tracing.http_client_requests' => false, + 'sentry' => $this->sentryConfigWith([ + 'traces_sample_rate' => 1.0, + 'tracing.http_client_requests' => false, + ]), ]); $transaction = $this->startTransaction(); @@ -199,8 +203,10 @@ public function testTracingHeadersAreAttached() public function testTracingHeadersAreAttachedWhenLocalRecordingIsDisabled(): void { $this->resetApplicationWithConfig([ - 'sentry.tracing.http_client_requests' => false, - 'sentry.breadcrumbs.http_client_requests' => false, + 'sentry' => $this->sentryConfigWith([ + 'tracing.http_client_requests' => false, + 'breadcrumbs.http_client_requests' => false, + ]), ]); $mock = new MockHandler([new Response(200, [], 'OK')]); $client = new Client(['handler' => HandlerStack::create($mock)]); @@ -216,8 +222,10 @@ public function testTracingHeadersAreAttachedWhenLocalRecordingIsDisabled(): voi public function testTransferStatsCallbackIsNotWrappedWhenLocalOutputIsDisabled(): void { $this->resetApplicationWithConfig([ - 'sentry.tracing.http_client_requests' => false, - 'sentry.breadcrumbs.http_client_requests' => false, + 'sentry' => $this->sentryConfigWith([ + 'tracing.http_client_requests' => false, + 'breadcrumbs.http_client_requests' => false, + ]), ]); $observedOnStats = null; $existingOnStats = static function (TransferStats $stats): void { @@ -240,9 +248,11 @@ static function (RequestInterface $request, array $options) use (&$observedOnSta public function testLegacyEnableTracingOptionRecordsSpansWithoutAnExplicitSampler(): void { $this->resetApplicationWithConfig([ - 'sentry.enable_tracing' => true, - 'sentry.traces_sample_rate' => null, - 'sentry.breadcrumbs.http_client_requests' => false, + 'sentry' => $this->sentryConfigWith([ + 'enable_tracing' => true, + 'traces_sample_rate' => null, + 'breadcrumbs.http_client_requests' => false, + ]), ]); $transaction = $this->startTransaction(); $client = $this->makeClient([new Response(200, [], 'OK')]); diff --git a/tests/Sentry/ConfigTest.php b/tests/Sentry/ConfigTest.php index bf96a42a2..24a10d53e 100644 --- a/tests/Sentry/ConfigTest.php +++ b/tests/Sentry/ConfigTest.php @@ -4,9 +4,11 @@ namespace Hypervel\Tests\Sentry; +use Closure; use Hypervel\Sentry\Features\RedisFeature; use Hypervel\Sentry\Transport\HttpPoolTransport; use Hypervel\Sentry\Transport\Pool; +use Hypervel\Support\Env; use InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; use ReflectionProperty; @@ -73,6 +75,21 @@ public function testOldPoolsKeyIsNotUsed(): void $this->assertNull($this->app->make('config')->get('pools.sentry')); } + public function testLogsChannelLevelUsesCurrentEnvironmentNames(): void + { + $this->assertSame('warning', $this->withEnvironmentValues([ + 'SENTRY_LOG_LEVEL' => 'warning', + 'SENTRY_LOGS_LEVEL' => 'error', + 'LOG_LEVEL' => 'info', + ], fn (): string => $this->sentryConfig()['logs_channel_level'])); + + $this->assertSame('info', $this->withEnvironmentValues([ + 'SENTRY_LOG_LEVEL' => null, + 'SENTRY_LOGS_LEVEL' => 'error', + 'LOG_LEVEL' => 'info', + ], fn (): string => $this->sentryConfig()['logs_channel_level'])); + } + public function testRedisFeatureIsInDefaultFeaturesConfig(): void { $features = $this->app->make('config')->array('sentry.features'); @@ -80,6 +97,40 @@ public function testRedisFeatureIsInDefaultFeaturesConfig(): void $this->assertContains(RedisFeature::class, $features); } + public function testStorageTelemetryIsEnabledByDefault(): void + { + $config = $this->withEnvironmentValues([ + 'SENTRY_BREADCRUMBS_STORAGE_ENABLED' => null, + 'SENTRY_TRACE_STORAGE_ENABLED' => null, + ], fn (): array => $this->sentryConfig()); + + $this->assertTrue($config['breadcrumbs']['storage']); + $this->assertTrue($config['tracing']['storage']); + } + + public function testBooleanEnvironmentValuesAreNormalized(): void + { + $config = $this->withEnvironmentValues([ + 'SENTRY_STRICT_TRACE_CONTINUATION' => '1', + 'SENTRY_ENABLE_METRICS' => '0', + 'SENTRY_SEND_DEFAULT_PII' => '1', + 'SENTRY_BREADCRUMBS_SQL_QUERIES_ENABLED' => '1', + 'SENTRY_BREADCRUMBS_CACHE_ENABLED' => '1', + 'SENTRY_TRACE_VIEWS_ENABLED' => '1', + 'SENTRY_TRACE_REDIS_COMMANDS' => '1', + 'SENTRY_TRACE_SQL_ORIGIN_THRESHOLD_MS' => '250', + ], fn (): array => $this->sentryConfig()); + + $this->assertTrue($config['strict_trace_continuation']); + $this->assertFalse($config['enable_metrics']); + $this->assertTrue($config['send_default_pii']); + $this->assertTrue($config['breadcrumbs']['sql_queries']); + $this->assertTrue($config['breadcrumbs']['cache']); + $this->assertTrue($config['tracing']['views']); + $this->assertTrue($config['tracing']['redis_commands']); + $this->assertSame(250, $config['tracing']['sql_origin_threshold_ms']); + } + public function testPoolWaitTimeoutDefaultIsSetForFastFail(): void { // Default config should have a low wait_timeout for backpressure @@ -105,4 +156,61 @@ private function getPoolFromTransport(HttpPoolTransport $transport): Pool return $reflection->getValue($transport); } + + /** + * Load the package configuration. + */ + private function sentryConfig(): array + { + return require dirname(__DIR__, 2) . '/src/sentry/config/sentry.php'; + } + + /** + * Run a callback with temporary environment variable values. + * + * @param array $values + */ + private function withEnvironmentValues(array $values, Closure $callback): mixed + { + $originalValues = []; + + foreach ($values as $key => $value) { + $originalValues[$key] = [ + 'putenv' => getenv($key), + 'server_exists' => array_key_exists($key, $_SERVER), + 'server' => $_SERVER[$key] ?? null, + 'env_exists' => array_key_exists($key, $_ENV), + 'env' => $_ENV[$key] ?? null, + ]; + + unset($_SERVER[$key], $_ENV[$key]); + $value === null ? putenv($key) : putenv("{$key}={$value}"); + } + + Env::flushRepository(); + + try { + return $callback(); + } finally { + foreach ($originalValues as $key => $value) { + $value['putenv'] === false + ? putenv($key) + : putenv("{$key}={$value['putenv']}"); + + if ($value['server_exists']) { + $_SERVER[$key] = $value['server']; + } else { + unset($_SERVER[$key]); + } + + if ($value['env_exists']) { + $_ENV[$key] = $value['env']; + } else { + unset($_ENV[$key]); + } + } + + Env::flushRepository(); + } + } } diff --git a/tests/Sentry/CoroutineSafetyTest.php b/tests/Sentry/CoroutineSafetyTest.php index a0a095dad..d5128ebf8 100644 --- a/tests/Sentry/CoroutineSafetyTest.php +++ b/tests/Sentry/CoroutineSafetyTest.php @@ -11,7 +11,6 @@ use Hypervel\Sentry\Features\CacheFeature; use Hypervel\Sentry\Integration; use Hypervel\Sentry\Tracing\EventHandler as TracingEventHandler; -use Hypervel\Tests\TestCase; use Sentry\SentrySdk; use Sentry\Tracing\TransactionContext; use Swoole\Coroutine\Channel; @@ -22,7 +21,7 @@ * Verifies that instance properties and static properties used for per-request * mutable state are properly isolated between concurrent coroutines. */ -class CoroutineSafetyTest extends TestCase +class CoroutineSafetyTest extends SentryTestCase { public function testIntegrationTransactionNameIsIsolatedPerCoroutine() { @@ -53,7 +52,7 @@ public function testIntegrationTransactionNameIsIsolatedPerCoroutine() public function testTracingEventHandlerSpanStacksAreIsolatedPerCoroutine() { - $handler = new TracingEventHandler([]); + $handler = new TracingEventHandler(config()->array('sentry.tracing')); // We need a transaction on the hub for span operations to work $hub = SentrySdk::getCurrentHub(); diff --git a/tests/Sentry/EventHandler/DatabaseEventsTest.php b/tests/Sentry/EventHandler/DatabaseEventsTest.php index fa8d382a4..1e4f421f1 100644 --- a/tests/Sentry/EventHandler/DatabaseEventsTest.php +++ b/tests/Sentry/EventHandler/DatabaseEventsTest.php @@ -14,7 +14,7 @@ class DatabaseEventsTest extends SentryTestCase public function testSqlQueriesAreRecordedWhenEnabled(): void { $this->resetApplicationWithConfig([ - 'sentry.breadcrumbs.sql_queries' => true, + 'sentry' => $this->sentryConfigWith(['breadcrumbs.sql_queries' => true]), ]); $this->assertTrue($this->app->make('config')->boolean('sentry.breadcrumbs.sql_queries')); @@ -34,7 +34,7 @@ public function testSqlQueriesAreRecordedWhenEnabled(): void public function testSqlBindingsAreRecordedWhenEnabled(): void { $this->resetApplicationWithConfig([ - 'sentry.breadcrumbs.sql_bindings' => true, + 'sentry' => $this->sentryConfigWith(['breadcrumbs.sql_bindings' => true]), ]); $this->assertTrue($this->app->make('config')->boolean('sentry.breadcrumbs.sql_bindings')); @@ -55,7 +55,7 @@ public function testSqlBindingsAreRecordedWhenEnabled(): void public function testSqlQueriesAreRecordedWhenDisabled(): void { $this->resetApplicationWithConfig([ - 'sentry.breadcrumbs.sql_queries' => false, + 'sentry' => $this->sentryConfigWith(['breadcrumbs.sql_queries' => false]), ]); $this->assertFalse($this->app->make('config')->boolean('sentry.breadcrumbs.sql_queries')); @@ -73,7 +73,7 @@ public function testSqlQueriesAreRecordedWhenDisabled(): void public function testSqlBindingsAreRecordedWhenDisabled(): void { $this->resetApplicationWithConfig([ - 'sentry.breadcrumbs.sql_bindings' => false, + 'sentry' => $this->sentryConfigWith(['breadcrumbs.sql_bindings' => false]), ]); $this->assertFalse($this->app->make('config')->boolean('sentry.breadcrumbs.sql_bindings')); diff --git a/tests/Sentry/EventHandler/LogEventsTest.php b/tests/Sentry/EventHandler/LogEventsTest.php index e3bd43b94..1abd361d5 100644 --- a/tests/Sentry/EventHandler/LogEventsTest.php +++ b/tests/Sentry/EventHandler/LogEventsTest.php @@ -12,7 +12,7 @@ class LogEventsTest extends SentryTestCase public function testHypervelLogsAreRecordedWhenEnabled(): void { $this->resetApplicationWithConfig([ - 'sentry.breadcrumbs.logs' => true, + 'sentry' => $this->sentryConfigWith(['breadcrumbs.logs' => true]), ]); $this->assertTrue($this->app->make('config')->boolean('sentry.breadcrumbs.logs')); @@ -33,7 +33,7 @@ public function testHypervelLogsAreRecordedWhenEnabled(): void public function testHypervelLogsAreRecordedWhenDisabled(): void { $this->resetApplicationWithConfig([ - 'sentry.breadcrumbs.logs' => false, + 'sentry' => $this->sentryConfigWith(['breadcrumbs.logs' => false]), ]); $this->assertFalse($this->app->make('config')->boolean('sentry.breadcrumbs.logs')); diff --git a/tests/Sentry/EventHandlerTest.php b/tests/Sentry/EventHandlerTest.php index 2a0250615..9ab52bd55 100644 --- a/tests/Sentry/EventHandlerTest.php +++ b/tests/Sentry/EventHandlerTest.php @@ -21,7 +21,7 @@ class EventHandlerTest extends SentryTestCase { public function testMissingEventHandlerThrowsException(): void { - $handler = new EventHandler($this->app, []); + $handler = new EventHandler($this->app, config()->array('sentry')); $this->expectException(RuntimeException::class); @@ -75,7 +75,7 @@ public function testWorkerExitClosesTheTransportPoolWhenFlushFails(): void SentrySdk::setCurrentHub(new Hub($client)); try { - $handler = new EventHandler($this->app, []); + $handler = new EventHandler($this->app, config()->array('sentry')); $handler->workerExit(new OnWorkerExit(m::mock(Server::class), 1)); } finally { SentrySdk::setCurrentHub($previousHub); @@ -84,7 +84,7 @@ public function testWorkerExitClosesTheTransportPoolWhenFlushFails(): void private function tryAllEventHandlerMethods(array $methods): void { - $handler = new EventHandler($this->app, []); + $handler = new EventHandler($this->app, config()->array('sentry')); $methods = array_map(static function ($method) { return "{$method}Handler"; diff --git a/tests/Sentry/Features/CacheIntegrationTest.php b/tests/Sentry/Features/CacheIntegrationTest.php index fd6c20378..573d32fde 100644 --- a/tests/Sentry/Features/CacheIntegrationTest.php +++ b/tests/Sentry/Features/CacheIntegrationTest.php @@ -63,7 +63,7 @@ public function testCacheBreadcrumbForMissIsRecorded(): void public function testCacheBreadcrumbIsNotRecordedWhenDisabled(): void { $this->resetApplicationWithConfig([ - 'sentry.breadcrumbs.cache' => false, + 'sentry' => $this->sentryConfigWith(['breadcrumbs.cache' => false]), ]); $this->assertFalse($this->app->make('config')->boolean('sentry.breadcrumbs.cache')); diff --git a/tests/Sentry/Features/ConsoleIntegrationTest.php b/tests/Sentry/Features/ConsoleIntegrationTest.php index e2ab2ff90..f666cefaa 100644 --- a/tests/Sentry/Features/ConsoleIntegrationTest.php +++ b/tests/Sentry/Features/ConsoleIntegrationTest.php @@ -14,7 +14,7 @@ class ConsoleIntegrationTest extends SentryTestCase public function testCommandBreadcrumbIsRecordedWhenEnabled(): void { $this->resetApplicationWithConfig([ - 'sentry.breadcrumbs.command_info' => true, + 'sentry' => $this->sentryConfigWith(['breadcrumbs.command_info' => true]), ]); $this->assertTrue($this->app->make('config')->boolean('sentry.breadcrumbs.command_info')); @@ -30,7 +30,7 @@ public function testCommandBreadcrumbIsRecordedWhenEnabled(): void public function testCommandBreadcrumbIsNotRecordedWhenDisabled(): void { $this->resetApplicationWithConfig([ - 'sentry.breadcrumbs.command_info' => false, + 'sentry' => $this->sentryConfigWith(['breadcrumbs.command_info' => false]), ]); $this->assertFalse($this->app->make('config')->boolean('sentry.breadcrumbs.command_info')); diff --git a/tests/Sentry/Features/DatabaseIntegrationTest.php b/tests/Sentry/Features/DatabaseIntegrationTest.php index e9c4135d2..31ddd11a0 100644 --- a/tests/Sentry/Features/DatabaseIntegrationTest.php +++ b/tests/Sentry/Features/DatabaseIntegrationTest.php @@ -73,8 +73,10 @@ public function testSpanIsCreatedForSqliteConnectionQuery(): void public function testSqlBindingsAreRecordedWhenEnabled(): void { $this->resetApplicationWithConfig([ - 'sentry.traces_sample_rate' => 1.0, - 'sentry.tracing.sql_bindings' => true, + 'sentry' => $this->sentryConfigWith([ + 'traces_sample_rate' => 1.0, + 'tracing.sql_bindings' => true, + ]), ]); $span = $this->executeQueryAndRetrieveSpan( @@ -89,8 +91,10 @@ public function testSqlBindingsAreRecordedWhenEnabled(): void public function testSqlBindingsAreRecordedWhenDisabled(): void { $this->resetApplicationWithConfig([ - 'sentry.traces_sample_rate' => 1.0, - 'sentry.tracing.sql_bindings' => false, + 'sentry' => $this->sentryConfigWith([ + 'traces_sample_rate' => 1.0, + 'tracing.sql_bindings' => false, + ]), ]); $span = $this->executeQueryAndRetrieveSpan( @@ -105,9 +109,11 @@ public function testSqlBindingsAreRecordedWhenDisabled(): void public function testSqlOriginIsResolvedWhenEnabledAndOverTreshold(): void { $this->resetApplicationWithConfig([ - 'sentry.traces_sample_rate' => 1.0, - 'sentry.tracing.sql_origin' => true, - 'sentry.tracing.sql_origin_threshold_ms' => 10, + 'sentry' => $this->sentryConfigWith([ + 'traces_sample_rate' => 1.0, + 'tracing.sql_origin' => true, + 'tracing.sql_origin_threshold_ms' => 10, + ]), ]); $span = $this->executeQueryAndRetrieveSpan('SELECT 1', [], 20); @@ -118,8 +124,10 @@ public function testSqlOriginIsResolvedWhenEnabledAndOverTreshold(): void public function testSqlOriginIsNotResolvedWhenDisabled(): void { $this->resetApplicationWithConfig([ - 'sentry.traces_sample_rate' => 1.0, - 'sentry.tracing.sql_origin' => false, + 'sentry' => $this->sentryConfigWith([ + 'traces_sample_rate' => 1.0, + 'tracing.sql_origin' => false, + ]), ]); $span = $this->executeQueryAndRetrieveSpan('SELECT 1'); @@ -130,9 +138,11 @@ public function testSqlOriginIsNotResolvedWhenDisabled(): void public function testSqlOriginIsNotResolvedWhenUnderThreshold(): void { $this->resetApplicationWithConfig([ - 'sentry.traces_sample_rate' => 1.0, - 'sentry.tracing.sql_origin' => true, - 'sentry.tracing.sql_origin_threshold_ms' => 10, + 'sentry' => $this->sentryConfigWith([ + 'traces_sample_rate' => 1.0, + 'tracing.sql_origin' => true, + 'tracing.sql_origin_threshold_ms' => 10, + ]), ]); $span = $this->executeQueryAndRetrieveSpan('SELECT 1', [], 5); @@ -147,7 +157,7 @@ public function testSqlOriginIsNotResolvedWhenUnderThreshold(): void public function testQueryExecutedEventCreatesCorrectBreadcrumb(): void { $this->resetApplicationWithConfig([ - 'sentry.breadcrumbs.sql_bindings' => true, + 'sentry' => $this->sentryConfigWith(['breadcrumbs.sql_bindings' => true]), ]); $dispatcher = $this->app->make(Dispatcher::class); @@ -177,8 +187,10 @@ public function testQueryExecutedEventCreatesCorrectBreadcrumb(): void public function testQueryExecutedEventWithoutBindingsWhenDisabled(): void { $this->resetApplicationWithConfig([ - 'sentry.breadcrumbs.sql_queries' => true, - 'sentry.breadcrumbs.sql_bindings' => false, + 'sentry' => $this->sentryConfigWith([ + 'breadcrumbs.sql_queries' => true, + 'breadcrumbs.sql_bindings' => false, + ]), ]); $dispatcher = $this->app->make(Dispatcher::class); @@ -259,7 +271,7 @@ public function testTransactionRolledBackEventCreatesCorrectBreadcrumb(): void public function testQueryExecutedEventIsIgnoredWhenFeatureDisabled(): void { $this->resetApplicationWithConfig([ - 'sentry.breadcrumbs.sql_queries' => false, + 'sentry' => $this->sentryConfigWith(['breadcrumbs.sql_queries' => false]), ]); $dispatcher = $this->app->make(Dispatcher::class); @@ -280,7 +292,7 @@ public function testQueryExecutedEventIsIgnoredWhenFeatureDisabled(): void public function testTransactionEventIsIgnoredWhenFeatureDisabled(): void { $this->resetApplicationWithConfig([ - 'sentry.breadcrumbs.sql_transactions' => false, + 'sentry' => $this->sentryConfigWith(['breadcrumbs.sql_transactions' => false]), ]); $dispatcher = $this->app->make(Dispatcher::class); diff --git a/tests/Sentry/Features/NotificationsIntegrationTest.php b/tests/Sentry/Features/NotificationsIntegrationTest.php index d7b1ba2bf..27959d3fb 100644 --- a/tests/Sentry/Features/NotificationsIntegrationTest.php +++ b/tests/Sentry/Features/NotificationsIntegrationTest.php @@ -22,7 +22,6 @@ class NotificationsIntegrationTest extends SentryTestCase { protected array $defaultSetupConfig = [ 'sentry.traces_sample_rate' => 1.0, - 'sentry.tracing.views' => false, 'sentry.features' => [ NotificationsFeature::class, ], @@ -31,6 +30,7 @@ class NotificationsIntegrationTest extends SentryTestCase protected function defineEnvironment(ApplicationContract $app): void { parent::defineEnvironment($app); + $app->make('config')->set('sentry.tracing.views', false); $app->instance(ViewFactory::class, m::mock(ViewFactory::class)->shouldIgnoreMissing()); } @@ -77,11 +77,11 @@ public function testSkippedNotificationFinishesItsSpanSuccessfully(): void public function testSpanIsNotRecordedWhenDisabled(): void { $this->resetApplicationWithConfig([ - 'sentry.traces_sample_rate' => 1.0, - 'sentry.tracing.notifications' => false, - 'sentry.features' => [ - NotificationsFeature::class, - ], + 'sentry' => $this->sentryConfigWith([ + 'traces_sample_rate' => 1.0, + 'tracing.notifications' => false, + 'features' => [NotificationsFeature::class], + ]), ]); $this->sendNotificationAndExpectNoSpan(); @@ -101,10 +101,10 @@ public function testBreadcrumbIsRecorded(): void public function testBreadcrumbIsNotRecordedWhenDisabled(): void { $this->resetApplicationWithConfig([ - 'sentry.breadcrumbs.notifications' => false, - 'sentry.features' => [ - NotificationsFeature::class, - ], + 'sentry' => $this->sentryConfigWith([ + 'breadcrumbs.notifications' => false, + 'features' => [NotificationsFeature::class], + ]), ]); $this->sendTestNotification(); diff --git a/tests/Sentry/Features/RedisIntegrationTest.php b/tests/Sentry/Features/RedisIntegrationTest.php index 5c2cad14a..2717a9408 100644 --- a/tests/Sentry/Features/RedisIntegrationTest.php +++ b/tests/Sentry/Features/RedisIntegrationTest.php @@ -8,6 +8,7 @@ use Exception; use Hypervel\Context\RequestContext; use Hypervel\Contracts\Events\Dispatcher; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Contracts\Pool\PoolOptionInterface; use Hypervel\Contracts\Session\Session; use Hypervel\Http\Request; @@ -35,13 +36,20 @@ protected function setUp(): void protected array $defaultSetupConfig = [ 'sentry.traces_sample_rate' => 1.0, - 'sentry.tracing.redis_commands' => true, - 'sentry.tracing.redis_origin' => false, 'sentry.features' => [ RedisFeature::class, ], ]; + protected function defineEnvironment(ApplicationContract $app): void + { + parent::defineEnvironment($app); + + $config = $app->make('config'); + $config->set('sentry.tracing.redis_commands', true); + $config->set('sentry.tracing.redis_origin', false); + } + public function testFeatureIsApplicableWhenRedisCommandsTracingIsEnabled(): void { $feature = $this->app->make(RedisFeature::class); @@ -51,11 +59,8 @@ public function testFeatureIsApplicableWhenRedisCommandsTracingIsEnabled(): void public function testFeatureEnablesRedisEventsForFuturePools(): void { - $this->app->make('config')->set('database.redis.observed', [ - 'host' => '127.0.0.1', - 'port' => 6379, - 'database' => 0, - ]); + $config = $this->app->make('config'); + $config->set('database.redis.observed', $config->array('database.redis.default')); $this->assertTrue( $this->app->make(RedisConfig::class) @@ -65,14 +70,9 @@ public function testFeatureEnablesRedisEventsForFuturePools(): void public function testFeatureIsNotApplicableWhenRedisCommandsTracingIsDisabled(): void { - $this->resetApplicationWithConfig([ - 'sentry.tracing.redis_commands' => false, - 'sentry.features' => [ - RedisFeature::class, - ], - ]); + config()->set('sentry.tracing.redis_commands', false); - $feature = $this->app->make(RedisFeature::class); + $feature = new RedisFeature($this->app); $this->assertFalse($feature->isApplicable()); } @@ -418,12 +418,7 @@ private function setupMocks(string $connectionName = 'default', int $database = $this->app->instance(PoolFactory::class, $poolFactory); - $config = $this->app->make('config'); - $config->set("database.redis.{$connectionName}", [ - 'host' => '127.0.0.1', - 'port' => 6379, - 'database' => $database, - ]); + $this->app->make('config')->set("database.redis.{$connectionName}.database", $database); } private function createRedisConnection(string $name): RedisConnection diff --git a/tests/Sentry/Features/StorageIntegrationTest.php b/tests/Sentry/Features/StorageIntegrationTest.php index 5f79a15f6..67d69ab5b 100644 --- a/tests/Sentry/Features/StorageIntegrationTest.php +++ b/tests/Sentry/Features/StorageIntegrationTest.php @@ -472,6 +472,29 @@ public function testReturnsOriginalFilesystemWhenBothOutputsAreDisabled(): void $this->assertFalse($disk->exists('foo')); } + public function testGlobalFlagsDisableTelemetryWithoutDiskOverrides(): void + { + $breadcrumbs = config()->array('sentry.breadcrumbs'); + $breadcrumbs['storage'] = false; + $diskConfig = config()->array('filesystems.disks.local'); + $diskConfig['sentry_disk_name'] = 'local'; + $diskConfig['sentry_original_driver'] = $diskConfig['driver']; + $diskConfig['driver'] = 'sentry'; + $tracing = config()->array('sentry.tracing'); + $tracing['storage'] = false; + + $this->resetApplicationWithConfig([ + 'sentry.breadcrumbs' => $breadcrumbs, + 'sentry.tracing' => $tracing, + 'filesystems.disks.local' => $diskConfig, + ]); + + $disk = Storage::disk('local'); + + $this->assertNotInstanceOf(DecoratedFilesystem::class, $disk); + $this->assertFalse($disk->exists('foo')); + } + public function testResolvingDiskDoesNotModifyConfig(): void { $this->resetApplicationWithConfig([ diff --git a/tests/Sentry/FlushLifecycleTest.php b/tests/Sentry/FlushLifecycleTest.php index 6febceb64..6ef539a3f 100644 --- a/tests/Sentry/FlushLifecycleTest.php +++ b/tests/Sentry/FlushLifecycleTest.php @@ -12,7 +12,7 @@ use Hypervel\Sentry\Features\ConsoleIntegration as ConsoleFeature; use Hypervel\Sentry\Features\QueueFeature; use Hypervel\Sentry\Integration; -use Hypervel\Sentry\SdkCapabilities; +use Hypervel\Sentry\SentryConfig; use Hypervel\Tests\TestCase; use Mockery as m; use Sentry\ClientInterface; @@ -177,15 +177,12 @@ public function testConsoleCompletionPerformsABoundedDrain(): void ], ], ]); + $sentryConfig = new SentryConfig($config, 'sentry'); $container = m::mock(Container::class); $container->shouldReceive('make') - ->once() - ->with('config') - ->andReturn($config); - $container->shouldReceive('make') - ->once() - ->with(SdkCapabilities::class) - ->andReturn(new SdkCapabilities($config)); + ->twice() + ->with(SentryConfig::class) + ->andReturn($sentryConfig); $feature = new ConsoleFeature($container); $this->withHub(new Hub($client), static function () use ($feature): void { diff --git a/tests/Sentry/SentryTestCase.php b/tests/Sentry/SentryTestCase.php index 59da2b4d4..6b23f3226 100644 --- a/tests/Sentry/SentryTestCase.php +++ b/tests/Sentry/SentryTestCase.php @@ -8,6 +8,7 @@ use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Sentry\SentryServiceProvider; +use Hypervel\Support\Arr; use ReflectionMethod; use ReflectionProperty; use Sentry\Breadcrumb; @@ -103,6 +104,24 @@ protected function resetApplicationWithConfig(array $config): void $this->reloadApplication(); } + /** + * Return the complete shipped Sentry config with test-specific overrides. + * + * @param array $overrides + * + * @return array + */ + protected function sentryConfigWith(array $overrides): array + { + $config = config()->array('sentry'); + + foreach ($overrides as $key => $value) { + Arr::set($config, $key, $value); + } + + return $config; + } + protected function dispatchHypervelEvent(object $event, array $payload = []): void { $this->app->make('events')->dispatch($event, $payload); diff --git a/tests/Sentry/ServiceProviderListenerRegistrationTest.php b/tests/Sentry/ServiceProviderListenerRegistrationTest.php index 2b67c7ad3..3449f2bac 100644 --- a/tests/Sentry/ServiceProviderListenerRegistrationTest.php +++ b/tests/Sentry/ServiceProviderListenerRegistrationTest.php @@ -18,8 +18,10 @@ class ServiceProviderListenerRegistrationTest extends SentryTestCase public function testQueryExecutedIsNotRegisteredWhenSqlBreadcrumbsAndTracingAreDisabled(): void { $this->resetApplicationWithConfig([ - 'sentry.breadcrumbs.sql_queries' => false, - 'sentry.tracing.sql_queries' => false, + 'sentry' => $this->sentryConfigWith([ + 'breadcrumbs.sql_queries' => false, + 'tracing.sql_queries' => false, + ]), ]); $this->assertFalse(app('events')->hasListeners(QueryExecuted::class)); @@ -30,8 +32,10 @@ public function testQueryExecutedIsNotRegisteredWhenSqlBreadcrumbsAndTracingAreD public function testQueryExecutedIsRegisteredForBreadcrumbsWithoutTracing(): void { $this->resetApplicationWithConfig([ - 'sentry.breadcrumbs.sql_queries' => true, - 'sentry.tracing.sql_queries' => false, + 'sentry' => $this->sentryConfigWith([ + 'breadcrumbs.sql_queries' => true, + 'tracing.sql_queries' => false, + ]), ]); $this->assertTrue(app('events')->hasListeners(QueryExecuted::class)); @@ -41,8 +45,10 @@ public function testQueryExecutedIsRegisteredForBreadcrumbsWithoutTracing(): voi public function testQueryExecutedIsRegisteredForTracingWithoutBreadcrumbs(): void { $this->resetApplicationWithConfig([ - 'sentry.breadcrumbs.sql_queries' => false, - 'sentry.tracing.sql_queries' => true, + 'sentry' => $this->sentryConfigWith([ + 'breadcrumbs.sql_queries' => false, + 'tracing.sql_queries' => true, + ]), ]); $this->assertTrue(app('events')->hasListeners(QueryExecuted::class)); @@ -52,7 +58,9 @@ public function testQueryExecutedIsRegisteredForTracingWithoutBreadcrumbs(): voi public function testMessageLoggedIsNotRegisteredWhenLogBreadcrumbsAreDisabled(): void { $this->resetApplicationWithConfig([ - 'sentry.breadcrumbs.logs' => false, + 'sentry' => $this->sentryConfigWith([ + 'breadcrumbs.logs' => false, + ]), ]); $this->assertSame(0, $this->countMethodListeners(MessageLogged::class, 'messageLogged')); @@ -61,7 +69,9 @@ public function testMessageLoggedIsNotRegisteredWhenLogBreadcrumbsAreDisabled(): public function testMessageLoggedIsRegisteredWhenLogBreadcrumbsAreEnabled(): void { $this->resetApplicationWithConfig([ - 'sentry.breadcrumbs.logs' => true, + 'sentry' => $this->sentryConfigWith([ + 'breadcrumbs.logs' => true, + ]), ]); $this->assertTrue(app('events')->hasListeners(MessageLogged::class)); diff --git a/tests/Sentry/ServiceProviderTest.php b/tests/Sentry/ServiceProviderTest.php index 04a709eb9..d5bdc3fbd 100644 --- a/tests/Sentry/ServiceProviderTest.php +++ b/tests/Sentry/ServiceProviderTest.php @@ -18,6 +18,7 @@ use Hypervel\Sentry\Tracing\BacktraceHelper; use Hypervel\Sentry\Tracing\Middleware as TracingMiddleware; use Hypervel\Support\Facades\Artisan; +use LogicException; use Mockery as m; use Psr\Log\LoggerInterface; use RuntimeException; @@ -38,6 +39,16 @@ public function testIsBound(): void $this->assertInstanceOf(HubInterface::class, app('sentry')); } + public function testRegisteringASecondProviderFails(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage( + 'Sentry provider [' . ConflictingSentryServiceProvider::class . '] cannot be registered because another Sentry provider is already registered. Add [hypervel/sentry] to [extra.hypervel.dont-discover] before registering a custom provider, or remove the custom provider.' + ); + + $this->app->register(ConflictingSentryServiceProvider::class); + } + public function testEnvironment(): void { $this->assertEquals('testing', app('sentry')->getClient()->getOptions()->getEnvironment()); @@ -177,6 +188,10 @@ public function testFeatureCapabilitiesRequireAnActiveEndpointAndUsableBreadcrum $this->assertFalse($inactive->canRecordSpansForTest()); $this->assertFalse($inactive->canRecordBreadcrumbsForTest()); + config()->set('sentry.spotlight', '0'); + + $this->assertFalse((new InspectableSentryFeature($this->app))->canRecordSpansForTest()); + config()->set('sentry.spotlight', 'http://localhost:8969/stream'); config()->set('sentry.max_breadcrumbs', 0); @@ -228,10 +243,13 @@ public function testFeatureFailureIsLoggedWithoutOverwritingItsInstanceOrSkippin public function testTracingMiddlewareHonorsDisabledAfterResponseContinuation(): void { + $tracingConfig = config()->array('sentry.tracing'); + $tracingConfig['continue_after_response'] = false; + $tracingConfig['missing_routes'] = true; + $this->resetApplicationWithConfig([ 'sentry.traces_sample_rate' => 1.0, - 'sentry.tracing.continue_after_response' => false, - 'sentry.tracing.missing_routes' => true, + 'sentry.tracing' => $tracingConfig, ]); $middleware = $this->app->make(TracingMiddleware::class); $request = Request::create('/test', 'GET'); @@ -270,6 +288,11 @@ public function bootFeaturesForTest(): void } } +class ConflictingSentryServiceProvider extends SentryServiceProvider +{ + public static string $abstract = 'custom-sentry'; +} + class InspectableSentryFeature extends Feature { public function isApplicable(): bool diff --git a/tests/Sentry/ServiceProviderWithCustomAliasTest.php b/tests/Sentry/ServiceProviderWithCustomAliasTest.php index abd4f40db..a2d801b52 100644 --- a/tests/Sentry/ServiceProviderWithCustomAliasTest.php +++ b/tests/Sentry/ServiceProviderWithCustomAliasTest.php @@ -5,6 +5,8 @@ namespace Hypervel\Tests\Sentry; use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Di\Aop\AspectCollector; +use Hypervel\Sentry\Aspects\GuzzleHttpClientAspect; use Hypervel\Sentry\Facade; use Hypervel\Sentry\SentryServiceProvider; use Hypervel\Testbench\TestCase; @@ -12,16 +14,14 @@ class ServiceProviderWithCustomAliasTest extends TestCase { - protected function defineEnvironment(ApplicationContract $app): void + protected function getPackageProviders(ApplicationContract $app): array { $config = $app->make('config'); + // Sentry selects its AOP integrations during provider registration, before Testbench runs defineEnvironment(). $config->set('custom-sentry.dsn', 'http://publickey@sentry.dev/123'); $config->set('custom-sentry.error_types', E_ALL ^ E_DEPRECATED ^ E_USER_DEPRECATED); - } - protected function getPackageProviders(ApplicationContract $app): array - { return [ CustomSentryServiceProvider::class, ]; @@ -39,6 +39,7 @@ public function testIsBound(): void $this->assertTrue(app()->bound('custom-sentry')); $this->assertInstanceOf(HubInterface::class, app('custom-sentry')); $this->assertSame(app('custom-sentry'), CustomSentryFacade::getFacadeRoot()); + $this->assertNotEmpty(AspectCollector::getRule(GuzzleHttpClientAspect::class)); } public function testEnvironment(): void diff --git a/tests/Sentry/Tracing/EventHandlerTest.php b/tests/Sentry/Tracing/EventHandlerTest.php index 569b61fa6..1a87847ea 100644 --- a/tests/Sentry/Tracing/EventHandlerTest.php +++ b/tests/Sentry/Tracing/EventHandlerTest.php @@ -36,7 +36,7 @@ public function testMissingEventHandlerThrowsException(): void { $this->expectException(RuntimeException::class); - $handler = new EventHandler([]); + $handler = new EventHandler(config()->array('sentry.tracing')); /* @noinspection PhpUndefinedMethodInspection */ $handler->thisIsNotAHandlerAndShouldThrowAnException(); @@ -51,7 +51,7 @@ public function testAllMappedEventHandlersExist(): void public function testTransactionsAndQueriesAreOwnedByTheirExactConnection(): void { - $handler = new EventHandler([]); + $handler = new EventHandler(config()->array('sentry.tracing')); $transaction = $this->startTransaction(); $firstConnection = $this->connection('first'); $secondConnection = $this->connection('second'); @@ -87,7 +87,7 @@ public function testTransactionsAndQueriesAreOwnedByTheirExactConnection(): void public function testResponseAndTransactionSpansUseIndependentOwnership(): void { - $handler = new EventHandler([]); + $handler = new EventHandler(config()->array('sentry.tracing')); $transaction = $this->startTransaction(); $request = Request::create('/response'); $connection = $this->connection('response'); @@ -113,10 +113,10 @@ public function testResponseAndTransactionSpansUseIndependentOwnership(): void public function testNullQueryTimeCreatesAnInstantaneousSpanWithoutOriginResolution(): void { - $handler = new EventHandler([ - 'sql_origin' => true, - 'sql_origin_threshold_ms' => 0, - ]); + $tracingConfig = config()->array('sentry.tracing'); + $tracingConfig['sql_origin'] = true; + $tracingConfig['sql_origin_threshold_ms'] = 0; + $handler = new EventHandler($tracingConfig); $transaction = $this->startTransaction(); $handler->queryExecuted(new QueryExecuted( @@ -137,7 +137,7 @@ public function testThrowableFromInstrumentationDoesNotReachApplicationCode(): v $connection = m::mock(Connection::class); $connection->shouldReceive('getName')->once()->andReturn('throwing'); $connection->shouldReceive('getDatabaseName')->once()->andThrow(new Error('broken instrumentation')); - $handler = new EventHandler([]); + $handler = new EventHandler(config()->array('sentry.tracing')); $handler->queryExecuted(new QueryExecuted('select 1', [], 1.0, $connection)); @@ -156,7 +156,7 @@ public function testCoroutineExitFinishesOnlyAbandonedResponseAndTransactionSpan Coroutine::defer(static function () use (&$observedRestoredSpan): void { $observedRestoredSpan = SentrySdk::getCurrentHub()->getSpan(); }); - $handler = new EventHandler([]); + $handler = new EventHandler(config()->array('sentry.tracing')); $connection = $this->connection('abandoned'); $handler->responsePreparing(new PreparingResponse(Request::create('/abandoned'), 'payload')); $responseSpan = $hub->getSpan(); @@ -177,7 +177,7 @@ public function testCoroutineExitFinishesOnlyAbandonedResponseAndTransactionSpan private function tryAllEventHandlerMethods(array $methods): void { - $handler = new EventHandler([]); + $handler = new EventHandler(config()->array('sentry.tracing')); $methods = array_map(static function ($method) { return "{$method}Handler"; diff --git a/tests/Sentry/Tracing/MiddlewareTest.php b/tests/Sentry/Tracing/MiddlewareTest.php index 03f9b14d2..01367f61e 100644 --- a/tests/Sentry/Tracing/MiddlewareTest.php +++ b/tests/Sentry/Tracing/MiddlewareTest.php @@ -183,8 +183,7 @@ public function testAfterResponseSpanAppearsOnCapturedTransaction() public function testTerminateFinishesTransactionWhenAfterResponseTracingIsDisabled(): void { - config()->set('sentry.tracing.missing_routes', true); - $middleware = new Middleware(false); + $middleware = new Middleware(false, true); $request = Request::create('/test', 'GET'); $response = $middleware->handle($request, static fn () => new Response('OK')); From 83d2c28726937cfd98008f6ed74783d7ccea310b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:43:07 +0000 Subject: [PATCH 025/109] telescope: normalize watcher and storage configuration Cast env-backed enablement flags at the package boundary and use typed access for fixed watcher, middleware, storage, migration, and dashboard settings. Preserve nullable dashboard paths, queue settings, and polymorphic watcher definitions where omission has supported behavior. Update route, disabled-watcher, Redis, Reverb, and storage coverage, including a dedicated null-path route case and complete watcher records. --- src/telescope/config/telescope.php | 33 +++++------ ..._000000_create_telescope_entries_table.php | 4 +- .../resources/views/layout.blade.php | 5 +- .../src/Http/Controllers/EntryController.php | 2 +- src/telescope/src/RegistersWatchers.php | 4 +- src/telescope/src/Storage/EntryModel.php | 2 +- src/telescope/src/Telescope.php | 12 ++-- .../src/TelescopeServiceProvider.php | 8 +-- src/telescope/src/Watchers/CacheWatcher.php | 2 +- tests/Telescope/Http/NullPathRouteTest.php | 36 ++++++++++++ tests/Telescope/Http/RouteTest.php | 10 ++-- .../Watchers/DisabledWatcherTest.php | 55 ++++++++++++------- tests/Telescope/Watchers/RedisWatcherTest.php | 38 +++++++++++-- .../Telescope/Watchers/ReverbWatcherTest.php | 14 ++--- 14 files changed, 153 insertions(+), 72 deletions(-) create mode 100644 tests/Telescope/Http/NullPathRouteTest.php diff --git a/src/telescope/config/telescope.php b/src/telescope/config/telescope.php index e9792be7a..7f2eb913e 100644 --- a/src/telescope/config/telescope.php +++ b/src/telescope/config/telescope.php @@ -17,7 +17,7 @@ | */ - 'enabled' => env('TELESCOPE_ENABLED', true), + 'enabled' => (bool) env('TELESCOPE_ENABLED', true), /* |-------------------------------------------------------------------------- @@ -75,16 +75,17 @@ | */ - 'defer' => env('TELESCOPE_STORE_DEFER', true), + 'defer' => (bool) env('TELESCOPE_STORE_DEFER', true), /* |-------------------------------------------------------------------------- | Telescope Queue |-------------------------------------------------------------------------- | - | These options determine the queue connection and queue - | which will be used to process ProcessPendingUpdates jobs. This can - | be changed if you would prefer to use a non-default connection. + | These options determine the queue connection and queue which will be + | used to process ProcessPendingUpdates jobs. A null connection or queue + | uses the queue worker defaults, while a null or non-positive delay + | dispatches follow-up updates without a delay. | */ @@ -146,13 +147,13 @@ Watchers\BatchWatcher::class => env('TELESCOPE_BATCH_WATCHER', true), Watchers\CacheWatcher::class => [ - 'enabled' => env('TELESCOPE_CACHE_WATCHER', true), + 'enabled' => (bool) env('TELESCOPE_CACHE_WATCHER', true), 'hidden' => [], 'ignore' => [], ], Watchers\ClientRequestWatcher::class => [ - 'enabled' => env('TELESCOPE_CLIENT_REQUEST_WATCHER', true), + 'enabled' => (bool) env('TELESCOPE_CLIENT_REQUEST_WATCHER', true), 'ignore_hosts' => [], 'request_size_limit' => env('TELESCOPE_HTTP_CLIENT_REQUEST_SIZE_LIMIT', 64), 'response_size_limit' => env('TELESCOPE_HTTP_CLIENT_RESPONSE_SIZE_LIMIT', 64), @@ -166,12 +167,12 @@ ], Watchers\CommandWatcher::class => [ - 'enabled' => env('TELESCOPE_COMMAND_WATCHER', true), + 'enabled' => (bool) env('TELESCOPE_COMMAND_WATCHER', true), 'ignore' => [], ], Watchers\DumpWatcher::class => [ - 'enabled' => env('TELESCOPE_DUMP_WATCHER', true), + 'enabled' => (bool) env('TELESCOPE_DUMP_WATCHER', true), 'always' => env('TELESCOPE_DUMP_WATCHER_ALWAYS', false), ], @@ -180,14 +181,14 @@ // listener and is treated as a passive observer, so it will not cause // listener-guarded events to be fired just for Telescope. Watchers\EventWatcher::class => [ - 'enabled' => env('TELESCOPE_EVENT_WATCHER', true), + 'enabled' => (bool) env('TELESCOPE_EVENT_WATCHER', true), 'ignore' => [], ], Watchers\ExceptionWatcher::class => env('TELESCOPE_EXCEPTION_WATCHER', true), Watchers\GateWatcher::class => [ - 'enabled' => env('TELESCOPE_GATE_WATCHER', true), + 'enabled' => (bool) env('TELESCOPE_GATE_WATCHER', true), 'ignore_abilities' => [], 'ignore_packages' => true, 'ignore_paths' => [], @@ -196,14 +197,14 @@ Watchers\JobWatcher::class => env('TELESCOPE_JOB_WATCHER', true), Watchers\LogWatcher::class => [ - 'enabled' => env('TELESCOPE_LOG_WATCHER', true), + 'enabled' => (bool) env('TELESCOPE_LOG_WATCHER', true), 'level' => 'error', ], Watchers\MailWatcher::class => env('TELESCOPE_MAIL_WATCHER', true), Watchers\ModelWatcher::class => [ - 'enabled' => env('TELESCOPE_MODEL_WATCHER', true), + 'enabled' => (bool) env('TELESCOPE_MODEL_WATCHER', true), 'events' => ['eloquent.*'], 'hydrations' => true, ], @@ -211,7 +212,7 @@ Watchers\NotificationWatcher::class => env('TELESCOPE_NOTIFICATION_WATCHER', true), Watchers\QueryWatcher::class => [ - 'enabled' => env('TELESCOPE_QUERY_WATCHER', true), + 'enabled' => (bool) env('TELESCOPE_QUERY_WATCHER', true), 'ignore_packages' => true, 'ignore_paths' => [], 'slow' => 100, @@ -223,7 +224,7 @@ // WebSocket message and should only be used for targeted debugging, not sustained // production use. Watchers\ReverbWatcher::class => [ - 'enabled' => env('TELESCOPE_REVERB_WATCHER', true), + 'enabled' => (bool) env('TELESCOPE_REVERB_WATCHER', true), 'events' => [ 'connection_established', 'connection_closed', @@ -237,7 +238,7 @@ ], Watchers\RequestWatcher::class => [ - 'enabled' => env('TELESCOPE_REQUEST_WATCHER', true), + 'enabled' => (bool) env('TELESCOPE_REQUEST_WATCHER', true), 'size_limit' => env('TELESCOPE_RESPONSE_SIZE_LIMIT', 64), // KB 'ignore_http_methods' => [], 'ignore_status_codes' => [], diff --git a/src/telescope/database/migrations/2025_02_08_000000_create_telescope_entries_table.php b/src/telescope/database/migrations/2025_02_08_000000_create_telescope_entries_table.php index 0ead78c55..bba73c623 100644 --- a/src/telescope/database/migrations/2025_02_08_000000_create_telescope_entries_table.php +++ b/src/telescope/database/migrations/2025_02_08_000000_create_telescope_entries_table.php @@ -10,9 +10,9 @@ /** * Get the migration connection name. */ - public function getConnection(): ?string + public function getConnection(): string { - return config('telescope.storage.database.connection'); + return config()->string('telescope.storage.database.connection'); } /** diff --git a/src/telescope/resources/views/layout.blade.php b/src/telescope/resources/views/layout.blade.php index 5ef066b94..df8a7e668 100644 --- a/src/telescope/resources/views/layout.blade.php +++ b/src/telescope/resources/views/layout.blade.php @@ -1,3 +1,4 @@ +@php($applicationName = config()->string('app.name')) @@ -10,7 +11,7 @@ - Telescope{{ config('app.name') ? ' - ' . config('app.name') : '' }} + Telescope{{ $applicationName ? ' - ' . $applicationName : '' }} @@ -35,7 +36,7 @@ -

Hypervel Telescope{{ config('app.name') ? ' - ' . config('app.name') : '' }}

+

Hypervel Telescope{{ $applicationName ? ' - ' . $applicationName : '' }}