diff --git a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md
index 10ee7fcf7..3bb897163 100644
--- a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md
+++ b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md
@@ -428,7 +428,7 @@ Append package entries in checklist order. Keep each entry compact but complete
- **Architecture and inspected surface:** `enum_value()` deliberately preserves a backed enum's native `int|string` value because validation, database values, session/context keys, message groups, and serialization domains need that distinction. The defect is at string-only receivers: strict Hypervel managers, cache stores, carriers, and typed properties sometimes receive the raw integer, while truthiness-based defaults mistake enum value `0` for absence. The audit mechanically classified all 191 helper calls and all 438 `UnitEnum|string`-shaped source, contract, facade, and fake surfaces. The linked [detail plan](2026-07-15-framework-enum-identifier-contracts.md) records the complete design and implementation surface.
- **Shared finding `support-02` — Defect, Major, High:** Laravel-facing enum identifier contracts are inconsistent across Support Manager and its subclasses, Broadcasting, Mail, Notifications, Queue, Session, Concurrency, Socialite, Cache and its raw failover capability, Cookie, Database, Redis, Filesystem/Storage, scheduling, Pipeline transactions and named pipelines, Bus/Events/Foundation carriers, queue backends and commands, Horizon, authorization, Permission middleware and role checks, Foundation database testing helpers, Inertia once keys, Telescope tags/job queues, and downstream configured/CLI string identifiers. Effects include immediate or delayed `TypeError`, invalid integer state retained until dispatch, incorrect default selection for `0`, queue payload/storage/reserve/observation disagreement, a MailFake call escaping to the real manager, scalar QueueRoutes being misread as connection routes, PSR cache multi-get treating numeric enum identifiers as list indexes, and integer Telescope tags violating their persisted and monitored string contract. Exact null/empty comparisons preserve established empty-string fallback behavior while allowing the valid string identifier `"0"`; CacheManager retains Laravel's deliberate null-only behavior. The Redis Doctor/Benchmark detector also casts numeric store keys to strings, and Benchmark validates a nullable detection result before publishing its non-null store property.
- **Final rule:** Normalize an enum to `(string) enum_value($enum)` only at the owning string-identifier boundary, then match that boundary's current Laravel default-selection operator: exact null/empty fallback where upstream uses `?:`, null-only where upstream uses `??`. Leave ordinary strings on the shortest path. Preserve raw enum values in every value-oriented domain. Update contracts, facades, fakes, concrete managers, carriers, and tests together; do not add a global helper, service, value object, registry, trait, or catch-and-retry layer.
-- **Cross-package transaction:** Support owns the base Manager contract and exact fallback in the string-only MultipleInstanceManager. Contracts and public metadata converge for Broadcasting, Mail, Notifications, Queue, Session, Concurrency, Socialite, Hash, JWT, maintenance mode, and inherited Reverb managers. Cache owns key/tag/event normalization, truthful `FailoverStore::getRaw()` delegation, the numeric-key `getMultiple()` correction, Redis command store detection, and the matching `UnitEnum|string` event-subclass contracts; its six PSR-16 methods remain inherited from the string-only PSR contract by design, while concrete multi-key metadata and the Cache facade describe the broader enum-aware Laravel convenience API. PHP's numeric-string array-key coercion also requires `RetrievesMultipleKeys` to cast back to string before calling Hypervel's strict Store API. Native multi-read implementations were traced and need no matching source change. Bus, Events, Foundation, Broadcasting, Queue, and Pipeline own immediate carrier, route, transaction, chain, and named-pipeline delegation. `ChainedBatch` keeps copied source options verbatim so a source `''` retains Laravel behavior and resolves at the downstream queue boundary; its exact overlay and remainder-inheritance guards independently preserve directly routed and next-job `"0"` identifiers that Laravel's truthiness drops. Queue's Database, Beanstalkd, Redis, and SQS backends plus queue/Horizon commands preserve normalized zero names and established empty fallback through payload creation, storage, reserve/read, returned jobs, and observation; Redis changes logical/storage/reserved-job derivation together, while SQS deliberately keeps raw logical payload selection separate from URL/suffix resolution. Database, Redis, Filesystem/Storage, Cookie, Console scheduling, Foundation authorization/database test helpers, Permission, Mail, Inertia, Telescope, Session, Sanctum, Scout, Translation, Routing, Testbench, and the affected commands own their local string-identifier boundaries. Container and Eloquent attributes are revalidated as delegated consumers without adding duplicate normalization. Completed Events work and its Bus/Broadcasting consumers are revalidated in the same transaction.
+- **Cross-package transaction:** Support owns the base Manager contract and exact fallback in the string-only MultipleInstanceManager. Contracts and public metadata converge for Broadcasting, Mail, Notifications, Queue, Session, Concurrency, Socialite, Hash, JWT, maintenance mode, and inherited Reverb managers. Cache owns key/tag/event normalization, truthful `FailoverStore::getRaw()` delegation, the numeric-key `getMultiple()` correction, Redis command store detection, and the matching `UnitEnum|string` event-subclass contracts; its six PSR-16 methods remain inherited from the string-only PSR contract by design, while concrete multi-key metadata and the Cache facade describe the broader enum-aware Laravel convenience API. PHP's numeric-string array-key coercion also requires `RetrievesMultipleKeys` to cast back to string before calling Hypervel's strict Store API. Native multi-read implementations were traced and need no matching source change. Bus, Events, Foundation, Broadcasting, Queue, and Pipeline own immediate carrier, route, transaction, chain, and named-pipeline delegation. `ChainedBatch` keeps copied source options verbatim so a source `''` retains Laravel behavior and resolves at the downstream queue boundary; its exact overlay and remainder-inheritance guards independently preserve directly routed and next-job `"0"` identifiers that Laravel's truthiness drops. Queue's Database, Beanstalkd, Redis, and SQS backends plus queue/Horizon commands preserve normalized zero names and established empty fallback through payload creation, storage, reserve/read, returned jobs, and observation; Redis changes logical/storage/reserved-job derivation together, while SQS deliberately keeps raw logical payload selection separate from URL/suffix resolution. Database, Redis, Filesystem/Storage, Cookie, Console scheduling, Foundation authorization/database test helpers, Permission, Mail, Inertia, Telescope, Session, Sanctum, Scout, Translation, Routing, Testbench, and the affected commands own their local string-identifier boundaries. Container, Eloquent attributes, and Hashing's inherited manager routing are revalidated as delegated consumers without adding duplicate normalization. Completed Events work and its Bus/Broadcasting consumers are revalidated in the same transaction.
- **Same-family exact-zero corrections:** Permission guard discovery, default selection, role checks, Passport compatibility, and `permission:show` retain guard `"0"`; Database monitoring retains `--databases=0`; route conversion retains name `"0"`; Blueprint index creation and `dropMorphs()` retain explicit index `"0"`; and Testbench SQLite create/drop commands retain `--database=0` as `0.sqlite`. Each fix replaces only a falsey absence test at the owning boundary, preserves established null/empty behavior, and has focused wrong-target regression coverage.
- **Sibling finding `translation-01` — Defect, Minor, High:** ArrayLoader maps namespace `"0"` to the wildcard namespace in both `load()` and `addMessages()`. Use exact null/empty wildcard selection so namespace `"0"` remains addressable without changing Laravel's existing null/empty behavior.
- **Sibling finding `reverb-03` — Defect, Major, High:** Presence user ID `"0"` is discarded at subscription, unsubscription, and client-event propagation, while `ChannelConnection::data('0')` returns all data. Normalize stored presence IDs before exact empty-sentinel checks, keep the shared-state absence boundary nullable, retain the ID in rebroadcast/webhook payloads, and let only a null data key request the complete payload.
@@ -437,3 +437,118 @@ Append package entries in checklist order. Keep each entry compact but complete
- **Regression strategy:** Cover unit, string-backed, integer-backed, and zero-backed identifiers; manager/fake parity; cache key/tag/event/lock/limiter/multi-get and direct failover raw-read behavior; queue and broadcast carriers/routes; queue backend round trips that constrain payload/storage/reserve/returned-job agreement; chain, mailable, queue/Horizon command, and observed-job zero names; Pipeline transactions, named pipelines, and contextual-attribute delegation; cookie and scheduling paths; database/Redis/filesystem names; authorization/permission and Foundation database helpers; Inertia once keys; Telescope stored/monitored string tags; paired zero/empty behavior at every changed default boundary; Translation namespace `"0"`; Reverb presence user ID/data key `"0"`; and representative unchanged integer value domains. Normalized identifiers retain the validity of their direct string equivalent: request-side cookie key `"0"` resolves normally, while cookie creation proves that direct string and enum zero share Symfony's rejection. Replace tests that currently bless `TypeError` or delayed invalid state, run focused package suites, then `composer fix`. Laravel's current `Repository::getMultiple()` shares the numeric-enum key bug; include an upstream-ready report in the owner handoff, while leaving any external issue or pull request to owner coordination.
- **Validation and review:** Every changed identifier, fallback, carrier, backend, fake, facade, contract, command, and sibling regression is green. PHP CS Fixer, both PHPStan configurations, the complete parallel suite, both Testbench suites, `git diff --check`, the complete helper/signature/default-selection scans, a fresh full-diff caller/callee and test-integrity review, and independent code review are complete. The final review verified the exact null/empty versus null-only operator rules and found no accidental value-domain conversion, API widening, hot-path regression, stale code, or unnecessary abstraction.
- **Assessment:** The final transaction normalizes enums only where a string owner requires it and corrects falsey-zero loss at each domain's existing sentinel. It deliberately avoids a shared normalizer, registry, wrapper, context slot, or framework-wide string widening. Public Laravel call shapes remain compatible and current enum parity is restored. The later full Support and consumer-package audits must retain and revalidate these boundaries.
+
+### Correct Support utility boundaries and authentication timing isolation
+
+- **Architecture and inspected risk surfaces:** Support is primarily Laravel-derived and contains shared utilities, facades, manager infrastructure, file/config writers, test fakes, data conversion, number/string helpers, environment handling, and global test-state controls. The audit covered every Support source and test file, all repository callers of the candidate symbols, current Laravel source/tests/docs and originating pull requests where parity was missing, the completed callback-rebinding and enum-identifier work, Filesystem's atomic replacement contract, test-state cleanup order, and Auth's worker-cached `Timebox` consumers.
+
+| ID | Category | Severity | Confidence | Failure and owning boundary | Final decision |
+|---|---|---|---|---|---|
+| `support-03` | Defect | Minor | High | `EncodedHtmlString::encodeUsing()` accepts `callable` but assigns non-closure callables to a `?Closure` property | Normalize non-null callables with `Closure::fromCallable()` and retain null reset semantics |
+| `support-04` | Defect | Minor | High | Env's alphanumeric regex uses the invalid `A-z` range and admits punctuation | Use the current Laravel `A-Z` range |
+| `support-05` | Defect | Minor | High | Number formatting mishandles non-finite/negative file sizes, rounded unit rollover, non-positive pair steps, and non-finite trimming | Port the current Laravel corrections, retaining strict zero-step rejection, Hypervel's coroutine-local locale/currency state, and the PHPStan-clean integer exponent cast without changing output |
+| `support-06` | Userland footgun | Minor | High | DateFactory's worker-global mutators lack lifecycle warnings and its Carbon metadata has drifted | Port current annotations and mark all five public static mutators boot-only with the concrete worker-lifetime risk |
+| `support-07` | Maintainability | Minor | High | ServiceProvider retains stale PHPStan suppression comments in `pathsToPublish()` | Delete the obsolete suppressions without changing behavior |
+| `support-08` | Defect | Major | High | Provider registration rewrites PHP files in place and can publish a truncated file after an interrupted write | Preserve validated basic mode bits, replace the resolved target atomically, invalidate opcache after success, and throw on write failure |
+| `support-09` | Defect | Major | High | Env file rewrites use a truncate-then-write sequence and can leave partial configuration | Replace the validated target atomically while preserving basic mode bits |
+| `support-10` | Defect | Major | High | Composer JSON reads/writes can silently accept malformed input or publish partial output | Use checked Filesystem reads, throwing JSON decode/encode, and mode-preserving atomic replacement |
+| `support-11` | Userland footgun | Minor | High | Facade resolution callbacks and Dotenv loading mutate worker-lifetime state without lifecycle guidance | Add boot-only warnings only; runtime behavior remains unchanged |
+| `support-12` | Defect | Minor | High | Lottery's temporary global result configuration is not restored when callbacks or the operation throw | Restore the documented normal state in `finally` and mark test override APIs tests-only |
+| `support-13` | Defect | Minor | High | Str random/UUID/ULID sequence fallbacks leave temporary global factories installed when the fallback throws | Restore the previous sequence factory in `finally` |
+| `support-14` | Defect | Minor | High | Test cleanup flushes CoroutineContext and then calls `Once::flushState()`, recreating non-coroutine context state | Delete the redundant Once reset from both cleanup owners and assert the authoritative context flush leaves the fallback empty |
+| `support-17` | Defect | Minor | High | `Js::encode()` rejects invalid UTF-8 on its direct and `Jsonable` encoding paths instead of substituting it | Add `JSON_INVALID_UTF8_SUBSTITUTE` to both encoding boundaries |
+| `support-18` | Defect | Minor | High | `PendingMailFake::sendNow()` routes queueable mailables through asynchronous fake semantics | Fill the mailable and delegate directly to synchronous fake delivery |
+| `support-19` | Defect | Minor | High | `MailFake::assertQueuedTimes()` is protected despite current Laravel exposing it publicly | Restore current Laravel visibility and coverage |
+| `support-20` | Defect | Minor | High | PendingBatchFake lacks the documented `hasJobs()` assertion helper | Port the current Laravel fake-only helper and closure-reflection behavior |
+| `support-21` | Defect | Minor | High | Base62 encodes zero as an empty string and accepts negative values without a valid representation | Return `"0"` for zero and reject negatives explicitly |
+| `support-22` | Defect | Minor | High | Xml normalizes numeric element names only for scalar children, so nested numeric arrays generate inconsistent elements | Normalize the child name before both scalar and recursive branches |
+| `support-23` | Defect | Minor | High | Fileinfo MIME detection can throw when a stream disappears between metadata and use | Treat the native `ErrorException` from `finfo::file()` as an indeterminate MIME type and return null |
+| `support-24` | Defect | Major | High | DataObject reflection/conversion mishandles empty DTOs, untyped, union, and DNF properties, defaults, nested existing objects, child key conventions, child custom resolvers, and cache presence | Repair those exact reflection and recursion boundaries, skip unsupported DNF intersection members, cache empty maps truthfully, and retain the existing conversion architecture |
+| `support-25` | Defect | Minor | High | ExceptionHandlerFake can suppress `throwOnReport()` when `withoutExceptionHandling()` is active depending on setup order | Treat the active without-exception-handling marker as reportable while preserving normal fake filtering |
+| `support-26` | Defect | Minor | High | BusFake's three count assertions report grammatically incorrect singular counts and lag current Laravel's fake diagnostics | Pluralize the actual and expected counts at all three existing assertion boundaries and pin each public method |
+| `auth-01` | Defect | Major | High | Worker-cached SessionGuard and PasswordBroker instances share one mutable Timebox whose early-return state persists and races across authentication operations | Clone the configured Timebox for each of the five timed operations so every operation has isolated mutable timing state |
+
+- **Intentional omissions:** Laravel deferred-provider APIs remain omitted because their per-request bootstrap optimization conflicts with Hypervel's long-lived provider architecture; record the difference in Support's README and at the natural source boundary. `loadFactoriesFrom()` is directly deprecated by current Laravel and receives only a concise source `REMOVED:` marker. `Auth::routes()` exists solely for `laravel/ui`, which Hypervel does not integrate; retain the documented manual/Fortify route approach and record the omission in Auth's README plus the natural facade source location. Do not invent placeholder tests for omitted APIs.
+- **Approved Laravel parity additions:** Port current `Str::trans()`, `Str::counted()`, `Stringable::counted()`, `ValidatedInput::file()`, `BusFake::assertDispatchedOnce()`, and `QueueFake::assertPushedOnce()` from the current local Laravel branch, including current tests and proportionate public documentation. Originating pull requests identify the changed-file surface only; current upstream files are the implementation reference.
+- **Owner-approved deferrals:** Defer Bus bulk dispatch and Queue fake callback/job-inspection capability expansion to their package audits. Defer Laravel's Image capability to a separate feature decision. These are feature additions rather than Support correctness defects.
+- **Important rejected concerns:** Do not add writer locks, a generic atomic transaction layer, a generic callable normalizer, Composer timeouts, arbitrary DataObject union coercion or serializer hierarchy, a Timebox context/reset abstraction, a second CoroutineContext flush, Number locale save/restore, manager/static cache redesign, `Fluent`/`ValidatedInput`/`UriQueryString` key-`"0"` divergence, Optional's unreachable ArrayObject branch, or Xml JSON-roundtrip machinery without a demonstrated failure. Do not port deferred providers, `loadFactoriesFrom()`, `laravel/ui`, Image, or broader Bus/Queue features incidentally.
+- **Approved file-publication boundary:** The owner approved mode-preserving atomic replacement for the three CLI/bootstrap writer families. This intentionally creates a new inode and preserves only validated basic permission bits; ownership, ACLs, xattrs, and hard-link identity are not copied, directory write permission is required, and symlinks resolve to their target. These are the normal semantics of the existing Filesystem replacement primitive and avoid partial published configuration without adding locks or a transaction framework.
+- **Performance and compatibility:** Ordinary request paths are unchanged. File replacement and reflection metadata changes occur during CLI/bootstrap, testing, or DTO conversion. The authentication fix adds one clone only when an authentication or password-reset operation invokes Timebox, not on normal requests. Number/Str/fake parity keeps existing public shapes or adds current Laravel APIs. No new lock, retry, coroutine context, registry, worker cache, or per-request service resolution is introduced.
+- **Regression strategy:** Cover every callable shape, Number edge, atomic publication failure and mode, malformed/unencodable Composer JSON, exception-safe temporary factories, authoritative non-coroutine cleanup, fake parity, Base62 zero/negative values, nested numeric XML, MIME metadata/use races, every DataObject failure shape, ExceptionHandlerFake ordering, and independent Timebox state across every SessionGuard/PasswordBroker operation. Port current upstream tests/docs for the additive APIs and assert existing Laravel-facing behavior remains intact.
+- **Implementation:** Callable inputs now normalize to closures at the EncodedHtmlString boundary. Env, Composer JSON, and provider-bootstrap writers use checked reads/encoding plus mode-preserving atomic replacement, while provider publication invalidates opcache before reading and after replacement. The later shared Filesystem finding `filesystem-03` redacts the atomic replacement payload, including Env's complete secret-bearing environment contents, from exception traces; the later Encryption finding `encryption-03` redacts only Crypt's packed dynamic-dispatch arguments while retaining the base facade's diagnostics for every other facade. Number, Base62, Xml, Js, Fileinfo, DataObject, ValidatedInput, ExceptionHandlerFake, BusFake, QueueFake, MailFake, PendingMailFake, PendingBatchFake, Lottery, Str, DateFactory, DotenvManager, Facade, Once cleanup, and the approved current-Laravel Str/Stringable additions were corrected at their existing owners. SessionGuard and PasswordBroker clone the configured Timebox prototype for each timed operation. Facade metadata, public documentation, upstream provenance, and intentional omission records now match the implemented surface.
+- **Validation and review:** Every changed Support, Auth, Foundation-testing, Queue, Testbench, and cleanup regression is green. PHP CS Fixer, both PHPStan configurations, the complete parallel suite, both Testbench suites, `git diff --check`, broad stale-reference/API/parity scans, a fresh full-diff caller/callee and lifecycle review, and independent code review are complete. The final review added strict PendingBatchFake comparisons, confirmed the three local file-mode checks are preferable to a Laravel-divergent public Filesystem helper, and signed off with no remaining finding.
+- **Laravel-facing result:** Public API and configuration remain compatible. The work restores current Laravel fake, string, validated-input, Carbon metadata, and edge-case behavior; adds the matching facade metadata and documentation; and retains only the documented Hypervel omissions for deferred providers, `laravel/ui`, and Laravel's directly deprecated `loadFactoriesFrom()` surface.
+- **Assessment:** The final work fixes demonstrated utility, file-publication, fake, reflection, cleanup, and authentication-timing defects at their owning boundaries. Ordinary request paths are unchanged; the only new per-operation cost is one Timebox clone when an authentication or password-reset operation is actually timed. No lock, retry, coroutine context, registry, worker cache, general transaction/callable/serialization abstraction, or speculative compatibility machinery was added. The design is complete without overengineering.
+
+### Harden encryption rotation, key publication, and global lifecycle state
+
+- **Architecture and inspected risk surfaces:** Encryption is a Laravel-derived singleton encrypter configured once per application, with worker-global SerializableClosure security state and a cold CLI command that publishes application keys to `.env`. The audit covered every Encryption source and test file; encryption contracts, facade, Foundation configuration, test cleanup, Queue's SerializableClosure callbacks, current Laravel source/tests/docs and originating security changes, the vendored SerializableClosure globals, Filesystem's checked atomic replacement boundary, every repository caller of previous-key configuration, and same-family strict config parsing.
+
+| ID | Category | Severity | Confidence | Failure and owning boundary | Final decision |
+|---|---|---|---|---|---|
+| `encryption-01` | Defect | Major | High | CBC MAC validation short-circuits after the first matching key, exposing the matching key's position through timing during rotation | Port current Laravel's all-key CBC MAC validation, retain first-valid-key decryption and existing GCM behavior, and prove every configured CBC key is checked |
+| `encryption-02` | Defect | Major | High | Strict typing lets a missing or empty GCM tag escape as `TypeError` instead of the documented decryption failure | Reject null or non-16-byte AEAD tags explicitly with `DecryptException` |
+| `encryption-03` | Defect | Major | High | Plaintext and application keys can appear in exception argument traces across contracts, construction, validation, the global `encrypt()` helper, Crypt's packed facade arguments, provider parsing, and key publication | Mark the complete secret-bearing parameter chain with `SensitiveParameter`, including Foundation's helper and Crypt's local dynamic-dispatch boundary, without widening or changing signatures |
+| `encryption-04` | Defect | Major | High | `key:generate` performs unchecked reads and truncate-in-place writes, so native failure can surface as an unrelated type error or publish partial configuration while reporting success | Resolve the existing Filesystem service, use checked reads and mode-preserving atomic replacement, and preserve the established command behavior when no `APP_KEY` line exists |
+| `encryption-05` | Defect | Major | High | The key replacement pattern corrupts quoted empty keys, misses quoted current keys, and prefix-matches longer values | Match an optional paired quote around the exact current key and anchor the complete CRLF-safe line; write the canonical unquoted key |
+| `encryption-06` | Defect | Minor | High | `key:generate` omits current Laravel's existing command-prohibition integration | Use the existing `Prohibitable` trait, guard before mutation, and reset the per-command static flag between tests |
+| `encryption-07` | Defect | Minor | High | A literal null `APP_PREVIOUS_KEYS` crashes strict config loading in `explode()` | Cast the environment value to string at the config boundary |
+| `encryption-08` | Defect | Major | High | SerializableClosure's process-global signer and Queue callbacks survive application/test lifecycles and retain stale keys or application graphs | Make Encryption boot own set-or-clear of the signer and reset all three vendor globals in authoritative test cleanup |
+| `filesystem-03` | Defect | Major | High | Atomic replacement retains its complete content argument in exception traces, exposing `APP_KEY` and other environment secrets published by Encryption and Support | Mark only `Filesystem::replace()`'s content parameter sensitive; keep the broader generic write surface for evidence-based review during the full Filesystem audit |
+| `sanctum-01` | Defect | Minor | High | Sanctum has the same unguarded `explode(env())` shape, so a literal null stateful-domain value crashes strict config loading | Cast the environment value to string at the Sanctum config boundary and revalidate it during the later full Sanctum audit |
+
+- **Owner-approved security tradeoff:** CBC decryption with configured previous keys performs every HMAC check even when the current key matches. The owner approved the measured approximately 1.4 microseconds per additional previous key per CBC decrypt during active rotation. There is no extra work without previous keys and no GCM cost; removing retired keys removes the overhead.
+- **Important rejected concerns:** Do not constantize GCM decryption across keys, hot-reload or scope the application key per request, add command locking/retries/a general environment parser, add a public Filesystem mode API or shared mode trait for cold CLI duplication, change the cipher set or payload format, remove the protected `validMac()` extension seam, or add metadata/micro-optimization abstractions. Do not annotate the internal regex correction as a Laravel divergence; focused regressions and this ledger are the durable guards.
+- **Cross-package implications:** Encryption owns all eight primary findings. Contracts owns secret-parameter metadata and was revalidated; Foundation owns strict application config and the global `encrypt()` helper frame; Support owns Crypt's dynamic-dispatch frame and was revalidated; Testing owns SerializableClosure reset; Queue is revalidated as the callback installer; and Sanctum owns `sanctum-01`. Filesystem owns `filesystem-03`; its concrete atomic replacement boundary also protects Support's existing Env writer, while Encryption's consumer regression guards the complete key-publication chain. Route `filesystem-03` back into the later full Filesystem audit for an owning-package regression and evidence-based review of any other raw-write paths, route the helper boundary into the later full Foundation audit, and route `sanctum-01` back into the later full Sanctum audit.
+- **Implementation:** CBC decryption now validates every configured key's MAC and decrypts once with the first valid key, while GCM keeps its existing fallback flow. Missing/empty GCM tags fail through `DecryptException`. Secret-bearing parameters are redacted from both direct and indirect helper/facade traces without broadening the base Facade policy. `key:generate` uses the checked Filesystem service and mode-preserving atomic replacement, matches only the exact quoted or unquoted current key, and preserves CRLF through a zero-width line-ending assertion before committing runtime config. The command now supports current Laravel prohibition, Encryption boot set-or-clears the SerializableClosure signer, authoritative test cleanup resets all three vendor globals and the command flag, and strict application/Sanctum config parsing accepts literal-null environment values. No user-facing encryption documentation changes are required because public APIs and documented payload behavior remain unchanged.
+- **Performance and complexity:** `encryption-01` has the narrow owner-approved CBC rotation cost above. All other changes are metadata, boot, configuration, CLI, failure-path, test-cleanup, or an exact Crypt-local copy of the existing facade dispatch operations. The design adds no request-wide lock, retry, context slot, registry, cache, or new abstraction.
+- **Validation and review:** Focused Encryption, Foundation-config, Sanctum-config, and Testing-cleanup tests pass. PHP CS Fixer, both PHPStan configurations, the complete parallel suite, both Testbench suites, `git diff --check`, the package-list consistency check, runtime trace-redaction probes, a fresh full-diff caller/callee and lifecycle review, and independent code review are complete. The final review corrected CRLF replacement to assert rather than consume the carriage return and strengthened every quoted-key regression to compare the exact resulting file contents; the complete gate remained green afterward.
+- **Laravel-facing result:** Public APIs, configuration keys, payload format, cipher support, and extension shapes remain compatible. The work restores current Laravel security/parity where applicable and fixes strict-mode or upstream defects without adding a new public surface.
+- **Assessment:** The final work closes demonstrated timing, failure-contract, trace-disclosure, partial-publication, key-replacement, configuration, and worker-global lifecycle defects at their existing owners. Apart from the narrow owner-approved CBC rotation cost, ordinary hot paths are unchanged. No speculative state machine, lock, retry, context layer, registry, compatibility shim, or general abstraction was added; the result is complete without overengineering.
+
+### Correct hashing configuration, extension, and secret boundaries
+
+- **Architecture and inspected risk surfaces:** Hashing is a Laravel-derived synchronous password-hashing package. Its worker-cached manager and built-in drivers hold only boot configuration; per-call operations do not mutate them, and the existing Laravel setters are already documented as boot-only. The audit covered every Hashing source and test file; its contract, facade, provider, Foundation configuration and helper, Eloquent hashed-cast consumer, inherited Support Manager behavior, current Laravel source/tests and originating pull requests, exception-frame behavior with argument capture enabled, and the completed enum-identifier work.
+- **Implemented changes:** Bcrypt and Argon constructors now normalize environment-shaped configuration into their strict property types, while the existing Argon thread accessor owns its required cast. Both hashing implementations translate native `Error` failures into their stable framework exceptions without warning suppression or dead false-result branches. Plaintext parameters are redacted at every Hashing contract, implementation, manager, helper, and Hash-facade stack frame. `HashManager::isHashed()` now asks the configured cached driver, all verify-mode checks honor the existing protected algorithm extension seam, and the Hash facade's nullable metadata matches Hypervel's passwordless contract. The facade's local dispatcher intentionally mirrors the established Crypt boundary; no generic facade abstraction or base-Facade redaction was introduced.
+
+| ID | Category | Severity | Confidence | Failure and owning boundary | Final decision |
+|---|---|---|---|---|---|
+| `hashing-01` | Defect | Major | High | Numeric environment values remain strings and crash Hypervel's strictly typed hasher properties during driver construction | Normalize Bcrypt rounds, verify, and nullable limit plus Argon time, memory, threads, and verify at construction; keep the one required threads cast in the existing accessor because its strict return is validated before constructor assignment |
+| `hashing-02` | Defect | Minor | High | Invalid or unsupported `password_hash()` parameters throw native `Error` instead of the documented stable `RuntimeException`; the existing warning suppression and false checks cannot catch them | Port current Laravel's narrow `catch (Error)` boundary, remove dead suppression/false handling, and preserve Bcrypt's independent length `InvalidArgumentException` |
+| `hashing-03` | Defect | Critical | High | Plaintext can remain in Hashing contract, implementation, manager, helper, and facade frames when exception arguments are captured | Port Laravel's complete Hashing `SensitiveParameter` surface, mark the global `bcrypt()` helper, and mirror the established Crypt-local facade dispatcher so packed Hash arguments are redacted without hiding every facade's diagnostics |
+| `hashing-04` | Defect | Major | High | `HashManager::isHashed()` bypasses a custom driver's `info()` and can cause Eloquent's hashed cast to hash an already-hashed custom value again | Port Laravel's cached-driver delegation and pin custom-driver behavior |
+| `hashing-05` | Defect | Minor | High | Hash facade metadata rejects null even though Hypervel's passwordless hashing contract intentionally supports nullable `needsRehash()` input | Correct only the facade annotation to `string|null` |
+| `hashing-06` | Defect | Minor | High | Verify-mode checks inline built-in algorithm names and bypass the existing protected `isUsingCorrectAlgorithm()` extension seam | Delegate all three checks through the protected method, matching Laravel, and pin subclass override behavior |
+
+- **Approved performance tradeoff:** Driver-owned `isHashed()` adds one cached `driver()` lookup to each hashed-attribute assignment. A local one-million-call microbenchmark measured approximately 0.72 microseconds per call over the direct native lookup. The owner approved that bounded cost because it restores the supported custom-driver contract; adding a built-in fast path would split semantics for negligible savings. The algorithm-extension correction adds one method frame only when algorithm verification is enabled, around the same metadata lookup already performed, and is negligible beside `password_verify()`.
+- **Important rejected concerns:** Do not scope, clone, lock, pool, asynchronously offload, or cache hash operations; add a config DTO/validator, identifier normalizer, context slot, reset hook, generic secret registry, sensitive-facade trait, or base-Facade redaction; support request-time mutation of boot-only setters; widen unrelated per-call cost/memory/time coercion; or redesign algorithms, passwordless nullable behavior, and configuration structure. Synchronous password hashing and the existing worker-cached immutable driver shape remain correct.
+- **Cross-package implications:** Contracts owns Hashing's secret-parameter metadata; Foundation owns the `bcrypt()` helper frame; Support owns the Hash facade frame and completed Manager enum routing; Database owns the hashed-cast consumer that exposes custom-driver misclassification. The completed `support-02` boundary is revalidated without duplicate Hashing normalization: `HashManager` inherits `Manager::driver(UnitEnum|string|null)` and does not override it.
+- **Regression strategy:** Port current Laravel's invalid Bcrypt/Argon/Argon2id configuration regressions and default-cost assertion; cover environment-shaped numeric-string constructors and falsey verify input; prove a custom driver owns `isHashed()`; prove a subclass can override the algorithm gate; reflect every secret-bearing contract/helper/facade/manager/abstract/concrete parameter; and pin the Hash-local facade copy's forwarding, return, and missing-root behavior. Preserve Hypervel's nullable-hash/passwordless tests, the Bcrypt length exception, and all public call/config shapes. Run changed files, relevant Eloquent hashed-cast and Support Manager tests, then `composer fix` and a fresh full-diff review.
+- **Performance and compatibility:** Constructor normalization is cold. Error conversion is failure-only. Sensitive attributes and the local facade copy add no normal-call allocation, lookup, lock, yield, or retained state. The two approved costs are recorded above. Public Laravel methods, config keys, manager extension APIs, and documented behavior remain intact; the work restores current Laravel fixes and makes Hypervel's existing nullable contract metadata truthful.
+- **Validation and review:** The Hashing tests, sensitive-parameter reflection matrix, Hash-facade parity tests, Eloquent hashed-cast integration tests, and Support Manager revalidation are green. PHP CS Fixer, both PHPStan configurations, the complete parallel suite, both Testbench suites, and `git diff --check` pass. Fresh self-review and independent code review traced every changed caller and callee, confirmed the old defects fail for the intended reasons, and signed off with no source finding. The review deliberately retained Laravel's narrow `staticMethod.notFound` suppression for the internal `verifyConfiguration()` facade dispatch instead of advertising that internal method as public facade metadata.
+- **Assessment:** The accepted design uses primitive casts, native attributes, current Laravel's narrow error boundary, one driver delegation, and focused tests. It adds no concurrency machinery or new abstraction and deliberately rejects speculative robustness; the result is proportionate and not overengineered.
+
+### Correct cookie retrieval types and selective-encryption guidance
+
+- **Architecture and inspected risk surfaces:** Cookie is a Laravel-derived worker-cached jar whose boot defaults are intentionally shared while queued response cookies live in `CoroutineContext`. The audit covered every Cookie source and test file; contracts, facade, middleware configuration, request input, response attachment, authentication/session consumers, test cleanup, current Laravel source/tests/docs, the completed enum-identifier transaction, and all relevant repository callers.
+
+| ID | Category | Severity | Confidence | Failure and owning boundary | Final decision |
+|---|---|---|---|---|---|
+| `cookie-01` | Defect | Minor | High | Cookie facade `has()` and `get()` reject enum names before dispatch even though the jar already supports them | Accept `UnitEnum|string` keys, nullable for `get()`, and normalize only enum inputs at the facade request boundary |
+| `cookie-02` | Defect | Minor | High | Cookie facade `get()` advertises a mixed default but forwards it into the narrower Request method and can throw before applying it | Read without a default and apply the mixed fallback locally with null coalescing; retain precise no-default PHPStan metadata |
+| `cookie-03` | Defect | Minor | High | CookieJar and its contract declare request cookie values as nullable strings even though nested cookies are arrays | Match Request with `string|array|null` defaults and returns across the jar and contract |
+| `cookie-04` | Defect | Minor | High | `queued()` accepts a mixed default but declares `?Cookie`, so a legitimate non-Cookie sentinel throws on return | Make the concrete and facade metadata truthfully mixed while retaining conditional no-default precision |
+| `cookie-05` | Defect | Minor | High | The Cookie factory contract still describes a forever cookie as five years although the implementation and current Laravel use 400 days | Correct the stale contract description to 400 days |
+| `cookie-06` | Documentation | Minor | High | Public response documentation omits Hypervel's existing selective cookie-encryption mode and its security-significant precedence | Document `encryptCookies(only: [...])`, that unlisted cookies remain plaintext, and that a non-empty only list takes precedence over except |
+
+- **Type precision:** Describe `getQueuedCookies()` as an array of Symfony Cookie instances in the contract, concrete, and facade metadata so middleware consumers remain statically checked. Conditional PHPStan returns retain the common no-default result for facade `get()` and jar/facade `queued()` without adding runtime forwarding methods.
+- **Important rejected concerns:** Do not scope, clone, lock, pool, or wrap CookieJar; its only per-request mutation already lives in coroutine context. Do not add a state registry, queue abstraction, constant-time prefix scan, request/response redaction graph, cache, or broad enum widening. Do not duplicate `symfony/http-foundation` in Cookie's package manifest: Cookie's direct structural dependency on Hypervel HTTP already supplies it, and the repository deliberately does not mirror every imported transitive HTTP type into every package manifest.
+- **Cross-package implications:** Contracts owns the truthful QueueingFactory types; Support owns the Cookie facade; Boost owns the response documentation. The completed `support-02` enum boundary is revalidated at the Cookie facade without changing value-oriented helpers or unrelated response/helper APIs.
+- **Regression strategy:** Cover facade access through unit, string-backed, integer-backed, and zero-backed enum names; mixed facade defaults; nested request cookie arrays through the jar and contract; non-Cookie queued defaults; queued-cookie element metadata; and the existing selective-encryption precedence behavior. Run changed tests immediately, then `composer fix`, stale-reference scans, and a fresh caller/callee and lifecycle review.
+- **Performance and compatibility:** Ordinary string calls keep their existing path apart from the facade's required local fallback expression. Enum calls perform the already-established conversion, and all other changes are type metadata, tests, or documentation. No lock, allocation class, service resolution, context slot, cache, retained worker state, or new abstraction is added. Laravel-facing call shapes remain compatible; the changes make existing Hypervel unions and defaults truthful.
+- **Implementation:** Cookie facade request reads now normalize enum keys and apply mixed defaults at the facade boundary without passing them into Request's narrower API. CookieJar and QueueingFactory expose nested request arrays and array defaults truthfully, queued lookup exposes its existing mixed fallback, and queued-cookie element types remain precise across contract, concrete, and facade metadata. The contract duration text and public selective-encryption guidance now match the implemented 400-day and opt-in behavior. No package dependency or state architecture changed.
+- **Regression tests:** A package-local facade test covers unit, string-backed, integer-backed, and zero-backed names; missing-key mixed fallback; present-value precedence over a supplied object default; and the unchanged all-cookie read. Jar coverage proves nested arrays, array defaults, and non-Cookie queued sentinels. Existing middleware regressions continue to pin selective-encryption precedence.
+- **Validation and review:** Changed tests, the complete Cookie suite, focused Cookie/contracts/facade PHPStan, PHP CS Fixer, both full PHPStan configurations, the complete parallel suite, both Testbench suites, `git diff --check`, stale-signature scans, and a fresh caller/callee, fallback, enum-zero, state-lifetime, performance, and overengineering review are green. Independent code review added the present-value-with-default assertion and signed off with no remaining finding.
+- **Laravel-facing result:** Public Laravel call shapes and configuration remain compatible. Existing Hypervel enum and mixed-default surfaces now behave as declared, nested cookie arrays no longer fail at an over-narrow return type, and the existing Hypervel selective-encryption option is documented rather than redesigned.
+- **Assessment:** The accepted design fixes six demonstrated type/documentation defects at their existing boundaries and adds no concurrency machinery or speculative robustness. Worker-shared defaults and coroutine-local queue ownership remain unchanged.
+- **Owner pre-commit review:** The owner reviewed the completed work-unit summary and approved committing the signed-off implementation.
diff --git a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md
index 752fa0a00..b408ec071 100644
--- a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md
+++ b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md
@@ -990,9 +990,9 @@ An exceptionally large shared work unit may receive its own linked detail plan w
This compact index routes the completed-work history that must be consulted with the full plan after compaction. Detailed history remains in the [companion ledger](2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md).
-- **Active package or work unit:** `support`
-- **Ledger entries required for the active work:** `Isolate logging state and capture queued payloads deterministically` (`support-01`) and `Normalize framework enum identifiers at string boundaries` (`support-02`)
-- **Pending revalidation carried into the active work:** callback rebinding across every direct Manager consumer; enum identifier ownership across Support managers and their consumers; later full `foundation`, `queue`, and `sentry` audits must retain the completed Log boundaries
+- **Active package or work unit:** `engine`
+- **Ledger entries required for the active work:** None.
+- **Pending revalidation carried into the active work:** None.
Update these three lines when a package starts, completes, or gains a cross-package dependency. Name exact work-unit headings or shared finding IDs from the companion ledger; never use “see recent entries” or require a full-ledger reread.
@@ -1023,6 +1023,7 @@ Add one row only for a shared finding or changed lower-level assumption that ano
| `context-04` | `context` | `foundation`, `database`; later full consumer audits | `Correct explicit coroutine context targeting`; finding `context-04` |
| `di-02` | `di` | `foundation`, `sentry`, `telescope`; later full consumer audits | `Correct AOP proxy generation and publication`; finding `di-02` |
| `filesystem-02` | `filesystem` | `di` (revalidation complete); later full `filesystem` audit | `Correct AOP proxy generation and publication`; finding `filesystem-02` |
+| `filesystem-03` | `filesystem` | `encryption` (revalidation complete), `support`; later full `filesystem` audit | `Harden encryption rotation, key publication, and global lifecycle state`; finding `filesystem-03` |
| `events-01` | `foundation` | `events` (revalidation complete); later full `foundation` audit | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `events-01` |
| `events-03` | `events`, `queue` | later full `queue` audit | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `events-03` |
| `events-04` | `events`, `foundation` | later full `foundation` audit | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `events-04` |
@@ -1031,7 +1032,10 @@ Add one row only for a shared finding or changed lower-level assumption that ano
| `queue-11` | `queue` | `events` (revalidation complete), `broadcasting`; later full `queue` and `broadcasting` audits | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `queue-11` |
| `queue-12` | `bus`, `queue` | `events` (revalidation complete), `broadcasting`; later full `bus`, `queue`, and `broadcasting` audits | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `queue-12` |
| `foundation-01` | `foundation` | `support`; later full `foundation` and `support` audits | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `foundation-01` |
-| `support-02` | `support` | `auth`, `broadcasting`, `bus`, `cache`, `concurrency`, `console`, `container`, `contracts`, `cookie`, `database`, `events`, `filesystem`, `foundation`, `hashing`, `horizon`, `inertia`, `jwt`, `log`, `mail`, `notifications`, `permission`, `pipeline`, `queue`, `redis`, `reverb`, `routing`, `sanctum`, `scout`, `session`, `socialite`, `telescope`, `testbench`, `translation`; later full consumer audits | `Normalize framework enum identifiers at string boundaries`; finding `support-02`; sibling findings `translation-01` and `reverb-03`; linked detail plan `2026-07-15-framework-enum-identifier-contracts.md` |
+| `support-02` | `support` | `auth`, `broadcasting`, `bus`, `cache`, `concurrency`, `console`, `container`, `contracts`, `cookie`, `database`, `events`, `filesystem`, `foundation`, `hashing` (revalidation complete), `horizon`, `inertia`, `jwt`, `log`, `mail`, `notifications`, `permission`, `pipeline`, `queue`, `redis`, `reverb`, `routing`, `sanctum`, `scout`, `session`, `socialite`, `telescope`, `testbench`, `translation`; later full consumer audits | `Normalize framework enum identifiers at string boundaries`; finding `support-02`; sibling findings `translation-01` and `reverb-03`; linked detail plan `2026-07-15-framework-enum-identifier-contracts.md` |
+| `auth-01` | `support`, `auth` | later full `auth` audit | `Correct Support utility boundaries and authentication timing isolation`; finding `auth-01` |
+| `encryption-03` | `encryption` | `contracts` and `support` (revalidation complete), `filesystem`, `foundation`; later full `filesystem` and `foundation` audits | `Harden encryption rotation, key publication, and global lifecycle state`; finding `encryption-03` |
+| `sanctum-01` | `sanctum` | `encryption`; later full `sanctum` audit | `Harden encryption rotation, key publication, and global lifecycle state`; finding `sanctum-01` |
## Package checklist
@@ -1065,13 +1069,13 @@ The order is lower-level first where practical. Hypervel has cross-cutting depen
- [x] `di`
- [x] `events`
- [x] `log`
-- [ ] `support`
+- [x] `support`
### Security and request primitives
-- [ ] `encryption`
-- [ ] `hashing`
-- [ ] `cookie`
+- [x] `encryption`
+- [x] `hashing`
+- [x] `cookie`
### Coroutine and resource infrastructure
diff --git a/src/auth/README.md b/src/auth/README.md
index 86167ca0c..d241f5027 100644
--- a/src/auth/README.md
+++ b/src/auth/README.md
@@ -7,6 +7,7 @@ Auth for Hypervel
## Differences From Laravel
+- `Auth::routes()` is intentionally omitted because Hypervel does not integrate `laravel/ui`. Register authentication routes explicitly or use Fortify.
- Password brokers are guard-declared via `auth.guards.{guard}.passwords`; `auth.defaults.passwords` and `AUTH_PASSWORD_BROKER` do not exist, and bare `Password::` calls resolve through the current guard or throw.
- `auth.defaults.provider` does not exist; `getDefaultUserProvider()` returns the provider declared by the current default guard, and `createUserProvider(null)` means no provider.
- `guest:{guard}` selects the first named guard as the request's default guard on pass-through, mirroring how `auth:{guard}` selects on success.
diff --git a/src/auth/src/Passwords/PasswordBroker.php b/src/auth/src/Passwords/PasswordBroker.php
index 40ccfa167..f0f6dc505 100755
--- a/src/auth/src/Passwords/PasswordBroker.php
+++ b/src/auth/src/Passwords/PasswordBroker.php
@@ -50,7 +50,7 @@ public function __construct(
*/
public function sendResetLink(#[SensitiveParameter] array $credentials, ?Closure $callback = null): string
{
- return $this->timebox->call(function () use ($credentials, $callback) {
+ return (clone $this->timebox)->call(function () use ($credentials, $callback) {
// First we will check to see if we found a user at the given credentials and
// if we did not we will redirect back to this current URI with a piece of
// "flash" data in the session to indicate to the developers the errors.
@@ -96,7 +96,7 @@ public function sendResetLink(#[SensitiveParameter] array $credentials, ?Closure
*/
public function reset(#[SensitiveParameter] array $credentials, Closure $callback): mixed
{
- return $this->timebox->call(function ($timebox) use ($credentials, $callback) {
+ return (clone $this->timebox)->call(function ($timebox) use ($credentials, $callback) {
$user = $this->validateReset($credentials);
// If the responses from the validate method is not a user instance, we will
diff --git a/src/auth/src/SessionGuard.php b/src/auth/src/SessionGuard.php
index 877fa15a7..6ccd1fba5 100644
--- a/src/auth/src/SessionGuard.php
+++ b/src/auth/src/SessionGuard.php
@@ -263,7 +263,7 @@ public function onceUsingId(mixed $id): AuthenticatableContract|false
*/
public function validate(array $credentials = []): bool
{
- return $this->timebox->call(function ($timebox) use ($credentials) {
+ return (clone $this->timebox)->call(function ($timebox) use ($credentials) {
$this->setContextState(
'lastAttempted',
$user = $this->provider->retrieveByCredentials($credentials)
@@ -354,7 +354,7 @@ protected function failedBasicResponse(): never
*/
public function attempt(array $credentials = [], bool $remember = false): bool
{
- return $this->timebox->call(function ($timebox) use ($credentials, $remember) {
+ return (clone $this->timebox)->call(function ($timebox) use ($credentials, $remember) {
$this->fireAttemptEvent($credentials, $remember);
$this->setContextState(
@@ -389,7 +389,7 @@ public function attempt(array $credentials = [], bool $remember = false): bool
*/
public function attemptWhen(array $credentials = [], array|callable|null $callbacks = null, bool $remember = false): bool
{
- return $this->timebox->call(function ($timebox) use ($credentials, $callbacks, $remember) {
+ return (clone $this->timebox)->call(function ($timebox) use ($credentials, $callbacks, $remember) {
$this->fireAttemptEvent($credentials, $remember);
$this->setContextState(
diff --git a/src/boost/docs/data-objects.md b/src/boost/docs/data-objects.md
index 5f463965e..041256a16 100644
--- a/src/boost/docs/data-objects.md
+++ b/src/boost/docs/data-objects.md
@@ -108,6 +108,8 @@ $user->age;
// 39
```
+Missing values use their constructor defaults. Passing `null` explicitly is distinct from omitting a nullable value.
+
### Property Name Conversion
@@ -285,6 +287,8 @@ $user->address->street;
Nested resolution works recursively for nested data object properties. If you use a union type for an auto-resolved dependency, the union should include a data object or date / time type.
+You may also pass an existing nested data object instance. Auto-resolution preserves that instance instead of rebuilding it.
+
### Backed Enums
diff --git a/src/boost/docs/mail.md b/src/boost/docs/mail.md
index b4fc70445..c621c0a66 100644
--- a/src/boost/docs/mail.md
+++ b/src/boost/docs/mail.md
@@ -1415,6 +1415,7 @@ If you are queueing mailables for delivery in the background, you should use the
```php
Mail::assertQueued(OrderShipped::class);
+Mail::assertQueuedTimes(OrderShipped::class, 2);
Mail::assertNotQueued(OrderShipped::class);
Mail::assertNothingQueued();
Mail::assertQueuedCount(3);
diff --git a/src/boost/docs/queues.md b/src/boost/docs/queues.md
index 8690e980d..61d3735a4 100644
--- a/src/boost/docs/queues.md
+++ b/src/boost/docs/queues.md
@@ -3088,6 +3088,9 @@ test('orders can be shipped', function () {
// Assert a job was pushed
Queue::assertPushed(ShipOrder::class);
+ // Assert a job was pushed exactly once...
+ Queue::assertPushedOnce(ShipOrder::class);
+
// Assert a job was pushed twice...
Queue::assertPushedTimes(ShipOrder::class, 2);
@@ -3132,6 +3135,9 @@ class ExampleTest extends TestCase
// Assert a job was pushed
Queue::assertPushed(ShipOrder::class);
+ // Assert a job was pushed exactly once...
+ Queue::assertPushedOnce(ShipOrder::class);
+
// Assert a job was pushed twice...
Queue::assertPushedTimes(ShipOrder::class, 2);
@@ -3223,7 +3229,7 @@ Queue::fake()->serializeAndRestore();
### Testing Job Chains
-To test job chains, you will need to utilize the `Bus` facade's faking capabilities. The `Bus` facade's `assertChained` method may be used to assert that a [chain of jobs](/docs/{{version}}/queues#job-chaining) was dispatched. The `assertChained` method accepts an array of chained jobs as its first argument:
+To test job chains, you will need to utilize the `Bus` facade's faking capabilities. The `assertDispatchedOnce` method may be used to assert that a job was dispatched exactly once. The `Bus` facade's `assertChained` method may be used to assert that a [chain of jobs](/docs/{{version}}/queues#job-chaining) was dispatched. The `assertChained` method accepts an array of chained jobs as its first argument:
```php
use App\Jobs\RecordShipment;
@@ -3235,6 +3241,8 @@ Bus::fake();
// ...
+Bus::assertDispatchedOnce(ShipOrder::class);
+
Bus::assertChained([
ShipOrder::class,
RecordShipment::class,
diff --git a/src/boost/docs/responses.md b/src/boost/docs/responses.md
index b39d20199..b7095e1ef 100644
--- a/src/boost/docs/responses.md
+++ b/src/boost/docs/responses.md
@@ -188,6 +188,18 @@ By default, thanks to the `Hypervel\Cookie\Middleware\EncryptCookies` middleware
})
```
+Alternatively, you may encrypt only a specific set of cookies by passing them to the `only` argument. Every cookie not listed will remain unencrypted and readable by the client:
+
+```php
+->withMiddleware(function (Middleware $middleware): void {
+ $middleware->encryptCookies(only: [
+ 'session_cookie',
+ ]);
+})
+```
+
+When the `only` list is not empty, it takes precedence over the `except` list.
+
> [!NOTE]
> In general, cookie encryption should never be disabled, as this exposes your cookies to potential client-side data exposure and tampering.
diff --git a/src/boost/docs/strings.md b/src/boost/docs/strings.md
index 0d88a94be..03aaf8ca4 100644
--- a/src/boost/docs/strings.md
+++ b/src/boost/docs/strings.md
@@ -68,6 +68,7 @@ If you need to clear these caches during tests, you may call `StrCache::flushSta
[Str::contains](#method-str-contains)
[Str::containsAll](#method-str-contains-all)
[Str::convertCase](#method-str-convert-case)
+[Str::counted](#method-str-counted)
[Str::doesntContain](#method-str-doesnt-contain)
[Str::doesntEndWith](#method-str-doesnt-end-with)
[Str::doesntStartWith](#method-str-doesnt-start-with)
@@ -132,6 +133,7 @@ If you need to clear these caches during tests, you may call `StrCache::flushSta
[Str::take](#method-take)
[Str::title](#method-title-case)
[Str::toBase64](#method-str-to-base64)
+[Str::trans](#method-str-trans)
[Str::transliterate](#method-str-transliterate)
[Str::trim](#method-str-trim)
[Str::ltrim](#method-str-ltrim)
@@ -176,6 +178,7 @@ If you need to clear these caches during tests, you may call `StrCache::flushSta
[chopEnd](#method-fluent-str-chop-end)
[contains](#method-fluent-str-contains)
[containsAll](#method-fluent-str-contains-all)
+[counted](#method-fluent-str-counted)
[convertCase](#method-fluent-str-convert-case)
[decrypt](#method-fluent-str-decrypt)
[deduplicate](#method-fluent-str-deduplicate)
@@ -1297,6 +1300,23 @@ $password = Str::password(12);
// 'qwuar>#V|i]N'
```
+
+#### `Str::counted()` {.collection-method}
+
+The `Str::counted` method converts a singular word string to its singular or plural form based on the given count and prefixes the result with the formatted count:
+
+```php
+use Hypervel\Support\Str;
+
+$label = Str::counted('order', 1);
+
+// 1 order
+
+$label = Str::counted('order', 1000);
+
+// 1,000 orders
+```
+
#### `Str::plural()` {.collection-method}
@@ -1821,6 +1841,17 @@ $base64 = Str::toBase64('Hypervel');
// SHlwZXJ2ZWw=
```
+
+#### `Str::trans()` {.collection-method}
+
+The `Str::trans` method translates the given translation key and returns a fluent string instance:
+
+```php
+use Hypervel\Support\Str;
+
+$message = Str::trans('messages.welcome')->upper();
+```
+
#### `Str::transliterate()` {.collection-method}
@@ -3299,6 +3330,23 @@ $closure = Str::of('foo')->pipe(function (Stringable $str) {
// 'bar'
```
+
+#### `counted` {.collection-method}
+
+The `counted` method converts a singular word string to its singular or plural form based on the given count and prefixes the result with the formatted count:
+
+```php
+use Hypervel\Support\Str;
+
+$label = Str::of('order')->counted(1);
+
+// 1 order
+
+$label = Str::of('order')->counted(1000);
+
+// 1,000 orders
+```
+
#### `plural` {.collection-method}
diff --git a/src/boost/docs/validation.md b/src/boost/docs/validation.md
index c829f9791..b67bbd5c4 100644
--- a/src/boost/docs/validation.md
+++ b/src/boost/docs/validation.md
@@ -1113,6 +1113,14 @@ $validated = $request->safe()->except(['name', 'email']);
$validated = $request->safe()->all();
```
+Uploaded files may be retrieved from the validated input using the `file` method. The method returns the given default value when the input is missing or is not an uploaded file:
+
+```php
+$avatar = $request->safe()->file('avatar');
+
+$avatar = $request->safe()->file('avatar', $default);
+```
+
In addition, the `Hypervel\Support\ValidatedInput` instance may be iterated over and accessed like an array:
```php
diff --git a/src/contracts/src/Cookie/Factory.php b/src/contracts/src/Cookie/Factory.php
index dddec1e18..9811ae213 100644
--- a/src/contracts/src/Cookie/Factory.php
+++ b/src/contracts/src/Cookie/Factory.php
@@ -15,7 +15,7 @@ interface Factory
public function make(UnitEnum|string $name, ?string $value, int $minutes = 0, ?string $path = null, ?string $domain = null, ?bool $secure = null, bool $httpOnly = true, bool $raw = false, ?string $sameSite = null): Cookie;
/**
- * Create a cookie that lasts "forever" (five years).
+ * Create a cookie that lasts "forever" (400 days).
*/
public function forever(UnitEnum|string $name, string $value, ?string $path = null, ?string $domain = null, ?bool $secure = null, bool $httpOnly = true, bool $raw = false, ?string $sameSite = null): Cookie;
diff --git a/src/contracts/src/Cookie/QueueingFactory.php b/src/contracts/src/Cookie/QueueingFactory.php
index bbb3d441c..ae14a7e2e 100644
--- a/src/contracts/src/Cookie/QueueingFactory.php
+++ b/src/contracts/src/Cookie/QueueingFactory.php
@@ -4,6 +4,7 @@
namespace Hypervel\Contracts\Cookie;
+use Symfony\Component\HttpFoundation\Cookie;
use UnitEnum;
interface QueueingFactory extends Factory
@@ -16,7 +17,7 @@ public function has(UnitEnum|string $key): bool;
/**
* Get a cookie value from the current request.
*/
- public function get(UnitEnum|string $key, ?string $default = null): ?string;
+ public function get(UnitEnum|string $key, string|array|null $default = null): string|array|null;
/**
* Queue a cookie to send with the next response.
@@ -40,6 +41,8 @@ public function unqueue(UnitEnum|string $name, ?string $path = null): void;
/**
* Get the cookies which have been queued for the next request.
+ *
+ * @return array
*/
public function getQueuedCookies(): array;
}
diff --git a/src/contracts/src/Encryption/Encrypter.php b/src/contracts/src/Encryption/Encrypter.php
index 46c58d623..793cf5216 100644
--- a/src/contracts/src/Encryption/Encrypter.php
+++ b/src/contracts/src/Encryption/Encrypter.php
@@ -4,6 +4,8 @@
namespace Hypervel\Contracts\Encryption;
+use SensitiveParameter;
+
interface Encrypter
{
/**
@@ -11,7 +13,7 @@ interface Encrypter
*
* @throws \Hypervel\Contracts\Encryption\EncryptException
*/
- public function encrypt(mixed $value, bool $serialize = true): string;
+ public function encrypt(#[SensitiveParameter] mixed $value, bool $serialize = true): string;
/**
* Decrypt the given value.
diff --git a/src/contracts/src/Encryption/StringEncrypter.php b/src/contracts/src/Encryption/StringEncrypter.php
index acc97b671..0648d44dd 100644
--- a/src/contracts/src/Encryption/StringEncrypter.php
+++ b/src/contracts/src/Encryption/StringEncrypter.php
@@ -4,6 +4,8 @@
namespace Hypervel\Contracts\Encryption;
+use SensitiveParameter;
+
interface StringEncrypter
{
/**
@@ -11,7 +13,7 @@ interface StringEncrypter
*
* @throws \Hypervel\Contracts\Encryption\EncryptException
*/
- public function encryptString(string $value): string;
+ public function encryptString(#[SensitiveParameter] string $value): string;
/**
* Decrypt the given string without unserialization.
diff --git a/src/contracts/src/Hashing/Hasher.php b/src/contracts/src/Hashing/Hasher.php
index 9aaaa90da..caa2d637f 100644
--- a/src/contracts/src/Hashing/Hasher.php
+++ b/src/contracts/src/Hashing/Hasher.php
@@ -4,6 +4,8 @@
namespace Hypervel\Contracts\Hashing;
+use SensitiveParameter;
+
interface Hasher
{
/**
@@ -14,12 +16,12 @@ public function info(string $hashedValue): array;
/**
* Hash the given value.
*/
- public function make(string $value, array $options = []): string;
+ public function make(#[SensitiveParameter] string $value, array $options = []): string;
/**
* Check the given plain value against a hash.
*/
- public function check(string $value, ?string $hashedValue, array $options = []): bool;
+ public function check(#[SensitiveParameter] string $value, ?string $hashedValue, array $options = []): bool;
/**
* Check if the given hash has been hashed using the given options.
diff --git a/src/cookie/src/CookieJar.php b/src/cookie/src/CookieJar.php
index 316ec5157..ac7fd8f0c 100644
--- a/src/cookie/src/CookieJar.php
+++ b/src/cookie/src/CookieJar.php
@@ -56,7 +56,7 @@ public function has(UnitEnum|string $key): bool
/**
* Get a cookie value from the current request.
*/
- public function get(UnitEnum|string $key, ?string $default = null): ?string
+ public function get(UnitEnum|string $key, string|array|null $default = null): string|array|null
{
$request = RequestContext::getOrNull();
@@ -110,8 +110,10 @@ public function hasQueued(UnitEnum|string $key, ?string $path = null): bool
/**
* Get a queued cookie instance.
+ *
+ * @phpstan-return ($default is null ? Cookie|null : mixed)
*/
- public function queued(UnitEnum|string $key, mixed $default = null, ?string $path = null): ?Cookie
+ public function queued(UnitEnum|string $key, mixed $default = null, ?string $path = null): mixed
{
$key = $key instanceof UnitEnum ? (string) enum_value($key) : $key;
$queued = Arr::get($this->getQueuedCookiesRaw(), $key, []);
@@ -202,6 +204,8 @@ public function setDefaultPathAndDomain(string $path, ?string $domain, ?bool $se
/**
* Get the cookies which have been queued for the next request.
+ *
+ * @return array
*/
public function getQueuedCookies(): array
{
diff --git a/src/encryption/composer.json b/src/encryption/composer.json
index 484fe53c1..862c1ab6b 100644
--- a/src/encryption/composer.json
+++ b/src/encryption/composer.json
@@ -36,6 +36,7 @@
"hypervel/config": "^0.4",
"hypervel/console": "^0.4",
"hypervel/contracts": "^0.4",
+ "hypervel/filesystem": "^0.4",
"hypervel/support": "^0.4"
},
"config": {
@@ -51,4 +52,4 @@
"dev-main": "0.4-dev"
}
}
-}
\ No newline at end of file
+}
diff --git a/src/encryption/src/Commands/KeyGenerateCommand.php b/src/encryption/src/Commands/KeyGenerateCommand.php
index d7a5dcb9c..c63c88e5c 100644
--- a/src/encryption/src/Commands/KeyGenerateCommand.php
+++ b/src/encryption/src/Commands/KeyGenerateCommand.php
@@ -6,13 +6,18 @@
use Hypervel\Console\Command;
use Hypervel\Console\ConfirmableTrait;
+use Hypervel\Console\Prohibitable;
use Hypervel\Encryption\Encrypter;
+use Hypervel\Filesystem\Filesystem;
+use RuntimeException;
+use SensitiveParameter;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(name: 'key:generate')]
class KeyGenerateCommand extends Command
{
use ConfirmableTrait;
+ use Prohibitable;
/**
* The name and signature of the console command.
@@ -31,6 +36,10 @@ class KeyGenerateCommand extends Command
*/
public function handle()
{
+ if ($this->isProhibited()) {
+ return;
+ }
+
$key = $this->generateRandomKey();
if ($this->option('show')) {
@@ -62,7 +71,7 @@ protected function generateRandomKey(): string
/**
* Set the application key in the environment file.
*/
- protected function setKeyInEnvironmentFile(string $key): bool
+ protected function setKeyInEnvironmentFile(#[SensitiveParameter] string $key): bool
{
$currentKey = $this->hypervel->make('config')->get('app.key') ?? '';
@@ -80,12 +89,15 @@ protected function setKeyInEnvironmentFile(string $key): bool
/**
* Write a new environment file with the given key.
*/
- protected function writeNewEnvironmentFileWith(string $key): bool
+ protected function writeNewEnvironmentFileWith(#[SensitiveParameter] string $key): bool
{
+ $path = $this->hypervel->environmentFilePath();
+ $filesystem = $this->hypervel->make(Filesystem::class);
+
$replaced = preg_replace(
$this->keyReplacementPattern(),
'APP_KEY=' . $key,
- $input = file_get_contents($this->hypervel->environmentFilePath())
+ $input = $filesystem->get($path)
);
if ($replaced === $input || $replaced === null) {
@@ -98,7 +110,7 @@ protected function writeNewEnvironmentFileWith(string $key): bool
return false;
}
- file_put_contents($this->hypervel->environmentFilePath(), $replaced);
+ $filesystem->replace($path, $replaced, $this->fileMode($path));
return true;
}
@@ -108,8 +120,24 @@ protected function writeNewEnvironmentFileWith(string $key): bool
*/
protected function keyReplacementPattern(): string
{
- $escaped = preg_quote('=' . ($this->hypervel->make('config')->get('app.key') ?? ''), '/');
+ $escaped = preg_quote($this->hypervel->make('config')->get('app.key') ?? '', '/');
+
+ return "/^APP_KEY=([\"']?){$escaped}\\1(?=\\r?$)/m";
+ }
+
+ /**
+ * Get the basic permission bits for the environment file.
+ */
+ protected function fileMode(string $path): int
+ {
+ clearstatcache(true, $path);
+
+ $mode = @fileperms($path);
+
+ if ($mode === false) {
+ throw new RuntimeException("Unable to read permissions for environment file [{$path}].");
+ }
- return "/^APP_KEY{$escaped}/m";
+ return $mode & 0777;
}
}
diff --git a/src/encryption/src/Encrypter.php b/src/encryption/src/Encrypter.php
index e2f1bddc1..3f0bd3bc9 100644
--- a/src/encryption/src/Encrypter.php
+++ b/src/encryption/src/Encrypter.php
@@ -43,7 +43,7 @@ class Encrypter implements EncrypterContract, StringEncrypter
*
* @throws RuntimeException
*/
- public function __construct(string $key, string $cipher = 'aes-128-cbc')
+ public function __construct(#[SensitiveParameter] string $key, string $cipher = 'aes-128-cbc')
{
if (! static::supported($key, $cipher)) {
$ciphers = implode(', ', array_keys(self::$supportedCiphers));
@@ -58,7 +58,7 @@ public function __construct(string $key, string $cipher = 'aes-128-cbc')
/**
* Determine if the given key and cipher combination is valid.
*/
- public static function supported(string $key, string $cipher): bool
+ public static function supported(#[SensitiveParameter] string $key, string $cipher): bool
{
if (! isset(self::$supportedCiphers[strtolower($cipher)])) {
return false;
@@ -138,14 +138,19 @@ public function decrypt(string $payload, bool $unserialize = true): mixed
$tag = empty($payload['tag']) ? null : base64_decode($payload['tag'])
);
- $foundValidMac = false;
+ [$keys, $validKey] = [$this->getAllKeys(), null];
+
// Here we will decrypt the value. If we are able to successfully decrypt it
// we will then unserialize it and return it out to the caller. If we are
// unable to decrypt this value we will throw out an exception message.
- foreach ($this->getAllKeys() as $key) {
- if ($this->shouldValidateMac()
- && ! ($foundValidMac = $foundValidMac || $this->validMacForKey($payload, $key))
- ) {
+ foreach ($keys as $key) {
+ if ($this->shouldValidateMac()) {
+ $validMac = $this->validMacForKey($payload, $key);
+
+ if ($validMac && $validKey === null) {
+ $validKey = $key;
+ }
+
continue;
}
@@ -163,10 +168,21 @@ public function decrypt(string $payload, bool $unserialize = true): mixed
}
}
- if ($this->shouldValidateMac() && ! $foundValidMac) {
+ if ($this->shouldValidateMac() && $validKey === null) {
throw new DecryptException('The MAC is invalid.');
}
+ if ($this->shouldValidateMac()) {
+ $decrypted = openssl_decrypt(
+ $payload['value'],
+ strtolower($this->cipher),
+ $validKey,
+ 0,
+ $iv,
+ $tag ?? ''
+ );
+ }
+
if (($decrypted ?? false) === false) {
throw new DecryptException('Could not decrypt the data.');
}
@@ -244,7 +260,7 @@ protected function validMac(array $payload): bool
/**
* Determine if the MAC is valid for the given payload and key.
*/
- protected function validMacForKey(#[SensitiveParameter] array $payload, string $key): bool
+ protected function validMacForKey(#[SensitiveParameter] array $payload, #[SensitiveParameter] string $key): bool
{
return hash_equals(
$this->hash($payload['iv'], $payload['value'], $key),
@@ -257,7 +273,7 @@ protected function validMacForKey(#[SensitiveParameter] array $payload, string $
*/
protected function ensureTagIsValid(?string $tag): void
{
- if (self::$supportedCiphers[strtolower($this->cipher)]['aead'] && strlen($tag) !== 16) {
+ if (self::$supportedCiphers[strtolower($this->cipher)]['aead'] && ($tag === null || strlen($tag) !== 16)) {
throw new DecryptException('Could not decrypt the data.');
}
@@ -325,7 +341,7 @@ public function getPreviousKeys(): array
* Boot-only. Mutates the previous-key list; the encrypter is registered as
* a container singleton, so per-request use races across coroutines.
*/
- public function previousKeys(array $keys): static
+ public function previousKeys(#[SensitiveParameter] array $keys): static
{
foreach ($keys as $key) {
if (! static::supported($key, $this->cipher)) {
diff --git a/src/encryption/src/EncryptionServiceProvider.php b/src/encryption/src/EncryptionServiceProvider.php
index 8997a7bce..a3eeb5ac0 100644
--- a/src/encryption/src/EncryptionServiceProvider.php
+++ b/src/encryption/src/EncryptionServiceProvider.php
@@ -8,6 +8,7 @@
use Hypervel\Support\ServiceProvider;
use Hypervel\Support\Str;
use Laravel\SerializableClosure\SerializableClosure;
+use SensitiveParameter;
class EncryptionServiceProvider extends ServiceProvider
{
@@ -34,7 +35,7 @@ protected function registerEncrypter(): void
return (new Encrypter($this->parseKey($config), $config['cipher']))
->previousKeys(array_map(
- fn ($key) => $this->parseKey(['key' => $key]),
+ fn (#[SensitiveParameter] string $key) => $this->parseKey(['key' => $key]),
$config['previous_keys'] ?? []
));
});
@@ -47,17 +48,19 @@ protected function registerSerializableClosureSecurityKey(): void
{
$config = $this->app->make('config')->array('app');
- if (! class_exists(SerializableClosure::class) || empty($config['key'])) {
+ if (! class_exists(SerializableClosure::class)) {
return;
}
- SerializableClosure::setSecretKey($this->parseKey($config));
+ SerializableClosure::setSecretKey(
+ empty($config['key']) ? null : $this->parseKey($config)
+ );
}
/**
* Parse the encryption key.
*/
- protected function parseKey(array $config): string
+ protected function parseKey(#[SensitiveParameter] array $config): string
{
if (Str::startsWith($key = $this->key($config), $prefix = 'base64:')) {
$key = base64_decode(Str::after($key, $prefix));
@@ -71,7 +74,7 @@ protected function parseKey(array $config): string
*
* @throws \Hypervel\Encryption\MissingAppKeyException
*/
- protected function key(array $config): string
+ protected function key(#[SensitiveParameter] array $config): string
{
return tap($config['key'], function ($key) {
if (empty($key)) {
diff --git a/src/filesystem/src/Filesystem.php b/src/filesystem/src/Filesystem.php
index d2e38cd72..338cfa03f 100644
--- a/src/filesystem/src/Filesystem.php
+++ b/src/filesystem/src/Filesystem.php
@@ -13,6 +13,7 @@
use Hypervel\Support\Traits\Conditionable;
use Hypervel\Support\Traits\Macroable;
use RuntimeException;
+use SensitiveParameter;
use SplFileObject;
use Symfony\Component\Filesystem\Filesystem as SymfonyFilesystem;
use Symfony\Component\Finder\Finder;
@@ -221,7 +222,7 @@ public function put(string $path, $contents, bool $lock = false)
/**
* Write the contents of a file, replacing it atomically if it already exists.
*/
- public function replace(string $path, string $content, ?int $mode = null): void
+ public function replace(string $path, #[SensitiveParameter] string $content, ?int $mode = null): void
{
// If the path already exists and is a symlink, get the real path...
clearstatcache(true, $path);
diff --git a/src/foundation/config/app.php b/src/foundation/config/app.php
index 0e86966ff..863a4911a 100644
--- a/src/foundation/config/app.php
+++ b/src/foundation/config/app.php
@@ -192,7 +192,7 @@
'previous_keys' => [
...array_filter(
- explode(',', env('APP_PREVIOUS_KEYS', ''))
+ explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
),
],
diff --git a/src/foundation/src/helpers.php b/src/foundation/src/helpers.php
index 6c8ca29fd..2d40a0cf7 100644
--- a/src/foundation/src/helpers.php
+++ b/src/foundation/src/helpers.php
@@ -235,7 +235,7 @@ function base_path(string $path = ''): string
/**
* Hash the given value against the bcrypt algorithm.
*/
- function bcrypt(string $value, array $options = []): string
+ function bcrypt(#[\SensitiveParameter] string $value, array $options = []): string
{
/* @phpstan-ignore-next-line */
return app('hash')->driver('bcrypt')->make($value, $options);
@@ -478,7 +478,7 @@ function dispatch_sync(mixed $job, mixed $handler = null): mixed
/**
* Encrypt the given value.
*/
- function encrypt(mixed $value, bool $serialize = true): string
+ function encrypt(#[\SensitiveParameter] mixed $value, bool $serialize = true): string
{
/* @phpstan-ignore-next-line */
return app('encrypter')->encrypt($value, $serialize);
diff --git a/src/hashing/src/AbstractHasher.php b/src/hashing/src/AbstractHasher.php
index 334fbb60c..73dd980e1 100644
--- a/src/hashing/src/AbstractHasher.php
+++ b/src/hashing/src/AbstractHasher.php
@@ -4,6 +4,8 @@
namespace Hypervel\Hashing;
+use SensitiveParameter;
+
abstract class AbstractHasher
{
/**
@@ -17,7 +19,7 @@ public function info(string $hashedValue): array
/**
* Check the given plain value against a hash.
*/
- public function check(string $value, ?string $hashedValue, array $options = []): bool
+ public function check(#[SensitiveParameter] string $value, ?string $hashedValue, array $options = []): bool
{
if (! $this->hasHash($hashedValue)) {
return false;
diff --git a/src/hashing/src/Argon2IdHasher.php b/src/hashing/src/Argon2IdHasher.php
index a3f7c0a18..f6ef15292 100644
--- a/src/hashing/src/Argon2IdHasher.php
+++ b/src/hashing/src/Argon2IdHasher.php
@@ -5,6 +5,7 @@
namespace Hypervel\Hashing;
use RuntimeException;
+use SensitiveParameter;
class Argon2IdHasher extends ArgonHasher
{
@@ -13,13 +14,13 @@ class Argon2IdHasher extends ArgonHasher
*
* @throws RuntimeException
*/
- public function check(string $value, ?string $hashedValue, array $options = []): bool
+ public function check(#[SensitiveParameter] string $value, ?string $hashedValue, array $options = []): bool
{
if (! $this->hasHash($hashedValue)) {
return false;
}
- if ($this->verifyAlgorithm && $this->info($hashedValue)['algoName'] !== 'argon2id') {
+ if ($this->verifyAlgorithm && ! $this->isUsingCorrectAlgorithm($hashedValue)) {
throw new RuntimeException('This password does not use the Argon2id algorithm.');
}
diff --git a/src/hashing/src/ArgonHasher.php b/src/hashing/src/ArgonHasher.php
index 66d32b1ce..8ddf58086 100644
--- a/src/hashing/src/ArgonHasher.php
+++ b/src/hashing/src/ArgonHasher.php
@@ -4,8 +4,10 @@
namespace Hypervel\Hashing;
+use Error;
use Hypervel\Contracts\Hashing\Hasher as HasherContract;
use RuntimeException;
+use SensitiveParameter;
class ArgonHasher extends AbstractHasher implements HasherContract
{
@@ -34,10 +36,10 @@ class ArgonHasher extends AbstractHasher implements HasherContract
*/
public function __construct(array $options = [])
{
- $this->time = $options['time'] ?? $this->time;
- $this->memory = $options['memory'] ?? $this->memory;
+ $this->time = (int) ($options['time'] ?? $this->time);
+ $this->memory = (int) ($options['memory'] ?? $this->memory);
$this->threads = $this->threads($options);
- $this->verifyAlgorithm = $options['verify'] ?? $this->verifyAlgorithm;
+ $this->verifyAlgorithm = (bool) ($options['verify'] ?? $this->verifyAlgorithm);
}
/**
@@ -45,15 +47,15 @@ public function __construct(array $options = [])
*
* @throws RuntimeException
*/
- public function make(string $value, array $options = []): string
+ public function make(#[SensitiveParameter] string $value, array $options = []): string
{
- $hash = @password_hash($value, $this->algorithm(), [
- 'memory_cost' => $this->memory($options),
- 'time_cost' => $this->time($options),
- 'threads' => $this->threads($options),
- ]);
-
- if (! is_string($hash)) { // @phpstan-ignore function.alreadyNarrowedType (password_hash returns false if algorithm unavailable)
+ try {
+ $hash = password_hash($value, $this->algorithm(), [
+ 'memory_cost' => $this->memory($options),
+ 'time_cost' => $this->time($options),
+ 'threads' => $this->threads($options),
+ ]);
+ } catch (Error) {
throw new RuntimeException('Argon2 hashing not supported.');
}
@@ -73,13 +75,13 @@ protected function algorithm(): int|string
*
* @throws RuntimeException
*/
- public function check(string $value, ?string $hashedValue, array $options = []): bool
+ public function check(#[SensitiveParameter] string $value, ?string $hashedValue, array $options = []): bool
{
if (! $this->hasHash($hashedValue)) {
return false;
}
- if ($this->verifyAlgorithm && $this->info($hashedValue)['algoName'] !== 'argon2i') {
+ if ($this->verifyAlgorithm && ! $this->isUsingCorrectAlgorithm($hashedValue)) {
throw new RuntimeException('This password does not use the Argon2i algorithm.');
}
@@ -216,6 +218,6 @@ protected function threads(array $options): int
return 1;
}
- return $options['threads'] ?? $this->threads;
+ return (int) ($options['threads'] ?? $this->threads);
}
}
diff --git a/src/hashing/src/BcryptHasher.php b/src/hashing/src/BcryptHasher.php
index a27ce95e5..6bd91679f 100644
--- a/src/hashing/src/BcryptHasher.php
+++ b/src/hashing/src/BcryptHasher.php
@@ -4,9 +4,11 @@
namespace Hypervel\Hashing;
+use Error;
use Hypervel\Contracts\Hashing\Hasher as HasherContract;
use InvalidArgumentException;
use RuntimeException;
+use SensitiveParameter;
class BcryptHasher extends AbstractHasher implements HasherContract
{
@@ -31,8 +33,11 @@ class BcryptHasher extends AbstractHasher implements HasherContract
public function __construct(array $options = [])
{
$this->rounds = (int) ($options['rounds'] ?? $this->rounds);
- $this->verifyAlgorithm = $options['verify'] ?? $this->verifyAlgorithm;
- $this->limit = $options['limit'] ?? $this->limit;
+ $this->verifyAlgorithm = (bool) ($options['verify'] ?? $this->verifyAlgorithm);
+
+ $limit = $options['limit'] ?? $this->limit;
+
+ $this->limit = $limit === null ? null : (int) $limit;
}
/**
@@ -41,17 +46,17 @@ public function __construct(array $options = [])
* @throws RuntimeException
* @throws InvalidArgumentException
*/
- public function make(string $value, array $options = []): string
+ public function make(#[SensitiveParameter] string $value, array $options = []): string
{
- if ($this->limit && strlen($value) > $this->limit) {
- throw new InvalidArgumentException('Value is too long to hash. Value must be less than ' . $this->limit . ' bytes.');
- }
-
- $hash = password_hash($value, PASSWORD_BCRYPT, [
- 'cost' => $this->cost($options),
- ]);
-
- if ($hash === false) { // @phpstan-ignore identical.alwaysFalse (PHP 8 throws instead, kept for safety)
+ try {
+ if ($this->limit && strlen($value) > $this->limit) {
+ throw new InvalidArgumentException('Value is too long to hash. Value must be less than ' . $this->limit . ' bytes.');
+ }
+
+ $hash = password_hash($value, PASSWORD_BCRYPT, [
+ 'cost' => $this->cost($options),
+ ]);
+ } catch (Error) {
throw new RuntimeException('Bcrypt hashing not supported.');
}
@@ -63,13 +68,13 @@ public function make(string $value, array $options = []): string
*
* @throws RuntimeException
*/
- public function check(string $value, ?string $hashedValue, array $options = []): bool
+ public function check(#[SensitiveParameter] string $value, ?string $hashedValue, array $options = []): bool
{
if (! $this->hasHash($hashedValue)) {
return false;
}
- if ($this->verifyAlgorithm && $this->info($hashedValue)['algoName'] !== 'bcrypt') {
+ if ($this->verifyAlgorithm && ! $this->isUsingCorrectAlgorithm($hashedValue)) {
throw new RuntimeException('This password does not use the Bcrypt algorithm.');
}
diff --git a/src/hashing/src/HashManager.php b/src/hashing/src/HashManager.php
index 5b0d714bc..7b3734567 100644
--- a/src/hashing/src/HashManager.php
+++ b/src/hashing/src/HashManager.php
@@ -6,6 +6,7 @@
use Hypervel\Contracts\Hashing\Hasher;
use Hypervel\Support\Manager;
+use SensitiveParameter;
/**
* @mixin \Hypervel\Contracts\Hashing\Hasher
@@ -47,7 +48,7 @@ public function info(string $hashedValue): array
/**
* Hash the given value.
*/
- public function make(string $value, array $options = []): string
+ public function make(#[SensitiveParameter] string $value, array $options = []): string
{
return $this->driver()->make($value, $options);
}
@@ -55,7 +56,7 @@ public function make(string $value, array $options = []): string
/**
* Check the given plain value against a hash.
*/
- public function check(string $value, ?string $hashedValue, array $options = []): bool
+ public function check(#[SensitiveParameter] string $value, ?string $hashedValue, array $options = []): bool
{
return $this->driver()->check($value, $hashedValue, $options);
}
@@ -71,9 +72,9 @@ public function needsRehash(?string $hashedValue, array $options = []): bool
/**
* Determine if a given string is already hashed.
*/
- public function isHashed(string $value): bool
+ public function isHashed(#[SensitiveParameter] string $value): bool
{
- return password_get_info($value)['algo'] !== null;
+ return $this->driver()->info($value)['algo'] !== null;
}
/**
diff --git a/src/sanctum/config/sanctum.php b/src/sanctum/config/sanctum.php
index 4aaa70f0f..86f303b19 100644
--- a/src/sanctum/config/sanctum.php
+++ b/src/sanctum/config/sanctum.php
@@ -14,7 +14,7 @@
|
*/
- 'stateful_domains' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
+ 'stateful_domains' => explode(',', (string) env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
env('APP_URL') ? ',' . parse_url(env('APP_URL'), PHP_URL_HOST) : ''
diff --git a/src/support/README.md b/src/support/README.md
index 5747c1da7..69e94b651 100644
--- a/src/support/README.md
+++ b/src/support/README.md
@@ -3,8 +3,12 @@ Support for Hypervel
[](https://deepwiki.com/hypervel/support)
+Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Support
+
## Differences From Laravel
+Laravel's deferred service-provider metadata APIs are intentionally omitted. Hypervel registers providers once for a long-lived worker, so the per-request bootstrap optimization those APIs support does not apply.
+
Node package-manager detection walks from the current directory to the filesystem root, so applications inside a workspace use the nearest ancestor lockfile. Within each directory, detection prefers Bun, pnpm, Yarn, then npm.
pnpm and Yarn execute installed package binaries with `pnpm exec` and `yarn run`, keeping development tools tied to the project's installed versions.
diff --git a/src/support/src/Base62.php b/src/support/src/Base62.php
index 508a57d00..b1c507b09 100644
--- a/src/support/src/Base62.php
+++ b/src/support/src/Base62.php
@@ -14,9 +14,19 @@ class Base62
/**
* Encode the given number to a base62 string.
+ *
+ * @throws InvalidArgumentException
*/
public static function encode(int $number): string
{
+ if ($number < 0) {
+ throw new InvalidArgumentException('The number to encode must be greater than or equal to zero.');
+ }
+
+ if ($number === 0) {
+ return static::CHARS[0];
+ }
+
$chars = [];
while ($number > 0) {
$remain = $number % static::BASE;
diff --git a/src/support/src/Composer.php b/src/support/src/Composer.php
index b5382b756..0f4ce9722 100644
--- a/src/support/src/Composer.php
+++ b/src/support/src/Composer.php
@@ -7,6 +7,7 @@
use Closure;
use Composer\Autoload\ClassLoader;
use Hypervel\Filesystem\Filesystem;
+use JsonException;
use RuntimeException;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process;
@@ -44,11 +45,12 @@ public function __construct(Filesystem $files, ?string $workingPath = null)
/**
* Determine if the given Composer package is installed.
*
+ * @throws JsonException
* @throws RuntimeException
*/
public function hasPackage(string $package): bool
{
- $composer = json_decode(file_get_contents($this->findComposerFile()), true);
+ $composer = json_decode($this->files->get($this->findComposerFile()), true, 512, JSON_THROW_ON_ERROR);
return array_key_exists($package, $composer['require'] ?? [])
|| array_key_exists($package, $composer['require-dev'] ?? []);
@@ -107,22 +109,24 @@ public function removePackages(array $packages, bool $dev = false, Closure|Outpu
/**
* Modify the "composer.json" file contents using the given callback.
*
- * @param callable(array): array $callback
+ * @param callable(array): array $callback
*
+ * @throws JsonException
* @throws RuntimeException
*/
public function modify(callable $callback): void
{
$composerFile = $this->findComposerFile();
- $composer = json_decode(file_get_contents($composerFile), true, 512, JSON_THROW_ON_ERROR);
+ $composer = json_decode($this->files->get($composerFile), true, 512, JSON_THROW_ON_ERROR);
- file_put_contents(
+ $this->files->replace(
$composerFile,
json_encode(
call_user_func($callback, $composer),
- JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
- )
+ JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR
+ ),
+ $this->fileMode($composerFile),
);
}
@@ -170,13 +174,29 @@ protected function findComposerFile(): string
{
$composerFile = "{$this->workingPath}/composer.json";
- if (! file_exists($composerFile)) {
+ if (! $this->files->exists($composerFile)) {
throw new RuntimeException("Unable to locate `composer.json` file at [{$this->workingPath}].");
}
return $composerFile;
}
+ /**
+ * Get the basic permission bits for the Composer file.
+ */
+ protected function fileMode(string $path): int
+ {
+ clearstatcache(true, $path);
+
+ $mode = @fileperms($path);
+
+ if ($mode === false) {
+ throw new RuntimeException("Unable to read permissions for Composer file [{$path}].");
+ }
+
+ return $mode & 0777;
+ }
+
/**
* Get the PHP binary.
*/
diff --git a/src/support/src/DataObject.php b/src/support/src/DataObject.php
index 3ec7e1d08..3844b2ab5 100644
--- a/src/support/src/DataObject.php
+++ b/src/support/src/DataObject.php
@@ -40,7 +40,7 @@ abstract class DataObject implements ArrayAccess, JsonSerializable
/**
* Reversed property map cache (class name => [camelCase key => snake_case property]).
*/
- public static ?array $reversedPropertyMapCache = [];
+ public static array $reversedPropertyMapCache = [];
/**
* Flag to indicate if auto-casting is enabled.
@@ -215,26 +215,30 @@ protected static function getConvertedData(array $data): array
*/
protected static function getDependenciesData(): array
{
- if ($dependencies = static::$dependenciesMapCache[static::class] ?? null) {
- return $dependencies;
+ if (array_key_exists(static::class, static::$dependenciesMapCache)) {
+ return static::$dependenciesMapCache[static::class];
}
return static::$dependenciesMapCache[static::class] = static::resolveDependenciesMap(static::class);
}
- protected static function getDependencyFromUnionType(ReflectionUnionType $type): ReflectionNamedType
+ protected static function getDependencyFromUnionType(ReflectionUnionType $type): ?ReflectionNamedType
{
foreach ($type->getTypes() as $namedType) {
+ if (! $namedType instanceof ReflectionNamedType) {
+ continue;
+ }
+
$className = $namedType->getName();
if (
is_subclass_of($className, DataObject::class)
- || is_subclass_of($className, DateTimeInterface::class)
+ || is_a($className, DateTimeInterface::class, true)
) {
return $namedType;
}
}
- throw new RuntimeException('No valid dependency found in union type.');
+ return null;
}
/**
@@ -266,7 +270,7 @@ protected static function resolveDependenciesMap(string $class, array &$visited
$visited[$class] = true;
$reflection = new ReflectionClass($class);
$properties = $reflection->getProperties(ReflectionProperty::IS_PUBLIC);
- $customizedDependencies = static::getCustomizedDependencies();
+ $customizedDependencies = $class::getCustomizedDependencies();
$result = [];
foreach ($properties as $property) {
@@ -274,27 +278,34 @@ protected static function resolveDependenciesMap(string $class, array &$visited
continue;
}
$propertyType = $property->getType();
+
+ if (! $propertyType instanceof ReflectionNamedType && ! $propertyType instanceof ReflectionUnionType) {
+ continue;
+ }
+
$allowsNull = $propertyType->allowsNull();
if ($propertyType instanceof ReflectionUnionType) {
$allowsNull = static::hasNullableUnionType($propertyType);
$propertyType = static::getDependencyFromUnionType($propertyType);
+
+ if ($propertyType === null) {
+ continue;
+ }
}
- /** @var ReflectionNamedType $propertyType */
+
$typeName = $propertyType->getName();
+ $dataKey = $class::isAutoCasting()
+ ? $class::convertPropertyToDataKey($property->getName())
+ : $property->getName();
+
if (is_subclass_of($typeName, DataObject::class)) {
- $dataKey = $typeName::isAutoCasting()
- ? $typeName::convertPropertyToDataKey($property->getName())
- : $property->getName();
$result[$dataKey] = [
- 'handler' => [$typeName, 'make'],
+ 'handler' => fn ($value) => $value instanceof $typeName ? $value : $typeName::make($value),
'nullable' => $allowsNull,
'children' => static::resolveDependenciesMap($typeName, $visited),
];
continue;
}
- $dataKey = static::isAutoCasting()
- ? static::convertPropertyToDataKey($property->getName())
- : $property->getName();
if (enum_exists($typeName) && is_subclass_of($typeName, BackedEnum::class, true)) {
$result[$dataKey] = [
'handler' => fn ($value) => $value instanceof $typeName ? $value : $typeName::from($value),
@@ -307,7 +318,7 @@ protected static function resolveDependenciesMap(string $class, array &$visited
$result[$dataKey] = [
'handler' => $resolver,
'nullable' => $allowsNull,
- 'children' => static::resolveDependenciesMap($typeName, $visited),
+ 'children' => [],
];
continue;
}
@@ -321,23 +332,23 @@ protected static function resolveDependenciesMap(string $class, array &$visited
/**
* Recursively replace dependencies data in the given data array.
*/
- protected static function replaceDependenciesData(array $dependencies, array $data): ?array
+ protected static function replaceDependenciesData(array $dependencies, array $data): array
{
foreach ($dependencies as $key => $dependency) {
+ if (! array_key_exists($key, $data)) {
+ continue;
+ }
+
$handler = $dependency['handler'];
$children = $dependency['children'] ?? [];
$nullable = $dependency['nullable'] ?? false;
- $matched = $data[$key] ?? null;
+ $matched = $data[$key];
- if ($nullable && is_null($matched)) {
- $data[$key] = null;
+ if ($nullable && $matched === null) {
continue;
}
if (! is_array($matched)) {
- $data[$key] = call_user_func_array(
- $handler,
- [is_null($matched) ? [] : $matched]
- );
+ $data[$key] = $handler($matched === null ? [] : $matched);
continue;
}
@@ -345,7 +356,7 @@ protected static function replaceDependenciesData(array $dependencies, array $da
$data[$key] = static::replaceDependenciesData($children, $matched);
}
- $data[$key] = call_user_func_array($handler, [$data[$key]]);
+ $data[$key] = $handler($data[$key]);
}
return $data;
@@ -465,12 +476,13 @@ protected static function getDefaultValueForType(ReflectionParameter $parameter)
*/
protected static function getPropertyMap(): array
{
- if (! is_null($cache = static::$propertyMapCache[static::class] ?? null)) {
- return $cache;
+ if (array_key_exists(static::class, static::$propertyMapCache)) {
+ return static::$propertyMapCache[static::class];
}
$reflection = new ReflectionClass(static::class);
$properties = $reflection->getProperties(ReflectionProperty::IS_PUBLIC);
+ $map = [];
foreach ($properties as $property) {
if ($property->isStatic()) {
@@ -478,10 +490,10 @@ protected static function getPropertyMap(): array
}
$propName = $property->getName();
$snakeKey = static::convertPropertyToDataKey($propName);
- static::$propertyMapCache[static::class][$snakeKey] = $propName;
+ $map[$snakeKey] = $propName;
}
- return static::$propertyMapCache[static::class];
+ return static::$propertyMapCache[static::class] = $map;
}
/**
@@ -491,8 +503,8 @@ protected static function getPropertyMap(): array
*/
protected static function getReversedPropertyMap(): array
{
- if (! is_null($cache = static::$reversedPropertyMapCache[static::class] ?? null)) {
- return $cache;
+ if (array_key_exists(static::class, static::$reversedPropertyMapCache)) {
+ return static::$reversedPropertyMapCache[static::class];
}
return static::$reversedPropertyMapCache[static::class] = array_flip(
diff --git a/src/support/src/DateFactory.php b/src/support/src/DateFactory.php
index 9e3b958fc..6855fb0d1 100644
--- a/src/support/src/DateFactory.php
+++ b/src/support/src/DateFactory.php
@@ -5,6 +5,7 @@
namespace Hypervel\Support;
use Carbon\Factory;
+use Closure;
use InvalidArgumentException;
use RuntimeException;
@@ -12,74 +13,96 @@
* @see https://carbon.nesbot.com/docs/
* @see https://github.com/briannesbitt/Carbon/blob/master/src/Carbon/Factory.php
*
- * @method \Hypervel\Support\Carbon create($year = 0, $month = 1, $day = 1, $hour = 0, $minute = 0, $second = 0, $tz = null)
- * @method \Hypervel\Support\Carbon createFromDate($year = null, $month = null, $day = null, $tz = null)
- * @method null|\Hypervel\Support\Carbon createFromFormat($format, $time, $tz = null)
- * @method \Hypervel\Support\Carbon createFromTime($hour = 0, $minute = 0, $second = 0, $tz = null)
- * @method \Hypervel\Support\Carbon createFromTimeString($time, $tz = null)
- * @method \Hypervel\Support\Carbon createFromTimestamp($timestamp, $tz = null)
- * @method \Hypervel\Support\Carbon createFromTimestampMs($timestamp, $tz = null)
- * @method \Hypervel\Support\Carbon createFromTimestampUTC($timestamp)
- * @method \Hypervel\Support\Carbon createMidnightDate($year = null, $month = null, $day = null, $tz = null)
- * @method null|\Hypervel\Support\Carbon createSafe($year = null, $month = null, $day = null, $hour = null, $minute = null, $second = null, $tz = null)
+ * @method bool canBeCreatedFromFormat(?string $date, string $format)
+ * @method null|\Hypervel\Support\Carbon create($year = 0, $month = 1, $day = 1, $hour = 0, $minute = 0, $second = 0, $timezone = null)
+ * @method \Hypervel\Support\Carbon createFromDate($year = null, $month = null, $day = null, $timezone = null)
+ * @method null|\Hypervel\Support\Carbon createFromFormat($format, $time, $timezone = null)
+ * @method null|\Hypervel\Support\Carbon createFromIsoFormat(string $format, string $time, $timezone = null, ?string $locale = 'en', ?\Symfony\Contracts\Translation\TranslatorInterface $translator = null)
+ * @method null|\Hypervel\Support\Carbon createFromLocaleFormat(string $format, string $locale, string $time, $timezone = null)
+ * @method null|\Hypervel\Support\Carbon createFromLocaleIsoFormat(string $format, string $locale, string $time, $timezone = null)
+ * @method \Hypervel\Support\Carbon createFromTime($hour = 0, $minute = 0, $second = 0, $timezone = null)
+ * @method \Hypervel\Support\Carbon createFromTimeString(string $time, \DateTimeZone|string|int|null $timezone = null)
+ * @method \Hypervel\Support\Carbon createFromTimestamp(string|int|float $timestamp, \DateTimeZone|string|int|null $timezone = null)
+ * @method \Hypervel\Support\Carbon createFromTimestampMs(string|int|float $timestamp, \DateTimeZone|string|int|null $timezone = null)
+ * @method \Hypervel\Support\Carbon createFromTimestampMsUTC($timestamp)
+ * @method \Hypervel\Support\Carbon createFromTimestampUTC(float|int|string $timestamp)
+ * @method \Hypervel\Support\Carbon createMidnightDate($year = null, $month = null, $day = null, $timezone = null)
+ * @method null|\Hypervel\Support\Carbon createSafe($year = null, $month = null, $day = null, $hour = null, $minute = null, $second = null, $timezone = null)
+ * @method \Hypervel\Support\Carbon createStrict(?int $year = 0, ?int $month = 1, ?int $day = 1, ?int $hour = 0, ?int $minute = 0, ?int $second = 0, $timezone = null)
* @method void disableHumanDiffOption($humanDiffOption)
* @method void enableHumanDiffOption($humanDiffOption)
- * @method mixed executeWithLocale($locale, $func)
+ * @method mixed executeWithLocale(string $locale, callable $func)
* @method \Hypervel\Support\Carbon fromSerialized($value)
* @method array getAvailableLocales()
+ * @method array getAvailableLocalesInfo()
* @method array getDays()
+ * @method null|string getFallbackLocale()
+ * @method array getFormatsToIsoReplacements()
* @method int getHumanDiffOptions()
* @method array getIsoUnits()
- * @method array getLastErrors()
+ * @method array|false getLastErrors()
* @method string getLocale()
* @method int getMidDayAt()
+ * @method string getTimeFormatByPrecision(string $unitPrecision)
+ * @method null|Closure|string getTranslationMessageWith($translator, string $key, ?string $locale = null, ?string $default = null)
* @method null|\Hypervel\Support\Carbon getTestNow()
* @method \Symfony\Contracts\Translation\TranslatorInterface getTranslator()
- * @method int getWeekEndsAt()
- * @method int getWeekStartsAt()
+ * @method int getWeekEndsAt(?string $locale = null)
+ * @method int getWeekStartsAt(?string $locale = null)
* @method array getWeekendDays()
- * @method bool hasFormat($date, $format)
+ * @method bool hasFormat(string $date, string $format)
+ * @method bool hasFormatWithModifiers(string $date, string $format)
* @method bool hasMacro($name)
- * @method bool hasRelativeKeywords($time)
+ * @method bool hasRelativeKeywords(?string $time)
* @method bool hasTestNow()
- * @method \Hypervel\Support\Carbon instance($date)
+ * @method \Hypervel\Support\Carbon instance(\DateTimeInterface $date)
* @method bool isImmutable()
* @method bool isModifiableUnit($unit)
* @method bool isMutable()
* @method bool isStrictModeEnabled()
- * @method bool localeHasDiffOneDayWords($locale)
- * @method bool localeHasDiffSyntax($locale)
- * @method bool localeHasDiffTwoDayWords($locale)
+ * @method bool localeHasDiffOneDayWords(string $locale)
+ * @method bool localeHasDiffSyntax(string $locale)
+ * @method bool localeHasDiffTwoDayWords(string $locale)
* @method bool localeHasPeriodSyntax($locale)
- * @method bool localeHasShortUnits($locale)
- * @method void macro($name, $macro)
- * @method null|\Hypervel\Support\Carbon make($var)
- * @method \Hypervel\Support\Carbon maxValue()
- * @method \Hypervel\Support\Carbon minValue()
- * @method void mixin($mixin)
- * @method \Hypervel\Support\Carbon now($tz = null)
- * @method \Hypervel\Support\Carbon parse($time = null, $tz = null)
+ * @method bool localeHasShortUnits(string $locale)
+ * @method void macro(string $name, ?callable $macro)
+ * @method null|\Hypervel\Support\Carbon make($var, \DateTimeZone|string|null $timezone = null)
+ * @method void mixin(object|string $mixin)
+ * @method \Hypervel\Support\Carbon now(\DateTimeZone|string|int|null $timezone = null)
+ * @method \Hypervel\Support\Carbon parse(\DateTimeInterface|\Carbon\WeekDay|\Carbon\Month|string|int|float|null $time, \DateTimeZone|string|int|null $timezone = null)
+ * @method \Hypervel\Support\Carbon parseFromLocale(string $time, ?string $locale = null, \DateTimeZone|string|int|null $timezone = null)
* @method string pluralUnit(string $unit)
+ * @method null|\Hypervel\Support\Carbon rawCreateFromFormat(string $format, string $time, $timezone = null)
+ * @method \Hypervel\Support\Carbon rawParse(\DateTimeInterface|\Carbon\WeekDay|\Carbon\Month|string|int|float|null $time, \DateTimeZone|string|int|null $timezone = null)
* @method void resetMonthsOverflow()
* @method void resetToStringFormat()
* @method void resetYearsOverflow()
* @method void serializeUsing($callback)
* @method void setHumanDiffOptions($humanDiffOptions)
- * @method bool setLocale($locale)
+ * @method void setFallbackLocale(string $locale)
+ * @method void setLocale(string $locale)
* @method void setMidDayAt($hour)
- * @method void setTestNow($testNow = null)
- * @method void setToStringFormat($format)
+ * @method void setTestNow(mixed $testNow = null)
+ * @method void setTestNowAndTimezone(mixed $testNow = null, $timezone = null)
+ * @method void setToStringFormat(null|\Closure|string $format)
* @method void setTranslator(\Symfony\Contracts\Translation\TranslatorInterface $translator)
+ * @method void setWeekEndsAt($day)
+ * @method void setWeekStartsAt($day)
* @method void setWeekendDays($days)
* @method bool shouldOverflowMonths()
* @method bool shouldOverflowYears()
* @method string singularUnit(string $unit)
- * @method \Hypervel\Support\Carbon today($tz = null)
- * @method \Hypervel\Support\Carbon tomorrow($tz = null)
+ * @method void sleep(float|int $seconds)
+ * @method \Hypervel\Support\Carbon today(\DateTimeZone|string|int|null $timezone = null)
+ * @method \Hypervel\Support\Carbon tomorrow(\DateTimeZone|string|int|null $timezone = null)
+ * @method string translateTimeString(string $timeString, ?string $from = null, ?string $to = null, int $mode = \Carbon\CarbonInterface::TRANSLATE_ALL)
+ * @method string translateWith(\Symfony\Contracts\Translation\TranslatorInterface $translator, string $key, array $parameters = [], $number = null)
* @method void useMonthsOverflow($monthsOverflow = true)
* @method void useStrictMode($strictModeEnabled = true)
* @method void useYearsOverflow($yearsOverflow = true)
- * @method \Hypervel\Support\Carbon yesterday($tz = null)
+ * @method mixed withTestNow(mixed $testNow, callable $callback)
+ * @method static withTimeZone(null|\DateTimeZone|int|string $timezone)
+ * @method \Hypervel\Support\Carbon yesterday(\DateTimeZone|string|int|null $timezone = null)
*/
class DateFactory
{
@@ -110,6 +133,9 @@ class DateFactory
/**
* Use the given handler when generating dates (class name, callable, or factory).
*
+ * Boot-only. The selected handler persists in static state for the worker
+ * lifetime and affects date creation in every subsequent request.
+ *
* @throws InvalidArgumentException
*/
public static function use(mixed $handler): void
@@ -133,8 +159,8 @@ public static function use(mixed $handler): void
/**
* Use the default date class when generating dates.
*
- * Boot or tests only. Clears the worker-wide date-generation overrides
- * shared by every coroutine.
+ * Boot-only. Clearing the worker-wide handler affects date creation in
+ * every subsequent request.
*/
public static function useDefault(): void
{
diff --git a/src/support/src/DotenvManager.php b/src/support/src/DotenvManager.php
index 4ca0c97ca..396106a62 100644
--- a/src/support/src/DotenvManager.php
+++ b/src/support/src/DotenvManager.php
@@ -20,6 +20,9 @@ class DotenvManager
*
* This is a one-shot method — subsequent calls return early if values
* have already been loaded. Use reload() to re-read the env file.
+ *
+ * Boot-only. Loaded values and the one-shot cache persist for the worker
+ * lifetime and affect every subsequent request.
*/
public static function load(array $paths, ?string $name = null): void
{
@@ -36,6 +39,9 @@ public static function load(array $paths, ?string $name = null): void
* Same one-shot semantics as load(), but uses Dotenv's safeLoad()
* which returns an empty array if the file doesn't exist instead
* of throwing. InvalidFileException is still thrown for malformed files.
+ *
+ * Boot-only. Loaded values and the one-shot cache persist for the worker
+ * lifetime and affect every subsequent request.
*/
public static function safeLoad(array $paths, ?string $name = null): void
{
@@ -52,6 +58,9 @@ public static function safeLoad(array $paths, ?string $name = null): void
* Deletes previously loaded env vars from putenv, resets the Env
* repository's ImmutableWriter so it treats all keys as writable,
* then re-reads the env file.
+ *
+ * Boot-only. Reloading mutates process-global environment values observed
+ * by every concurrent and subsequent request in the worker.
*/
public static function reload(array $paths, ?string $name = null): void
{
diff --git a/src/support/src/EncodedHtmlString.php b/src/support/src/EncodedHtmlString.php
index 5509e7dd9..98f96f816 100644
--- a/src/support/src/EncodedHtmlString.php
+++ b/src/support/src/EncodedHtmlString.php
@@ -78,7 +78,7 @@ public function toHtml(): string
*/
public static function encodeUsing(?callable $factory = null): void
{
- static::$encodeUsingFactory = $factory;
+ static::$encodeUsingFactory = $factory === null ? null : Closure::fromCallable($factory);
}
/**
diff --git a/src/support/src/Env.php b/src/support/src/Env.php
index e2ca7a0ab..6bcbab5a8 100644
--- a/src/support/src/Env.php
+++ b/src/support/src/Env.php
@@ -243,13 +243,14 @@ public static function writeVariables(array $variables, string $pathToFile, bool
throw new RuntimeException("The file [{$pathToFile}] does not exist.");
}
+ $pathToFile = realpath($pathToFile) ?: $pathToFile;
$lines = explode(PHP_EOL, $filesystem->get($pathToFile));
foreach ($variables as $key => $value) {
$lines = self::addVariableToEnvContents($key, $value, $lines, $overwrite);
}
- $filesystem->put($pathToFile, implode(PHP_EOL, $lines));
+ $filesystem->replace($pathToFile, implode(PHP_EOL, $lines), self::fileMode($pathToFile));
}
/**
@@ -266,12 +267,13 @@ public static function writeVariable(string $key, mixed $value, string $pathToFi
throw new RuntimeException("The file [{$pathToFile}] does not exist.");
}
+ $pathToFile = realpath($pathToFile) ?: $pathToFile;
$envContent = $filesystem->get($pathToFile);
$lines = explode(PHP_EOL, $envContent);
$lines = self::addVariableToEnvContents($key, $value, $lines, $overwrite);
- $filesystem->put($pathToFile, implode(PHP_EOL, $lines));
+ $filesystem->replace($pathToFile, implode(PHP_EOL, $lines), self::fileMode($pathToFile));
}
/**
@@ -286,7 +288,7 @@ protected static function addVariableToEnvContents(string $key, mixed $value, ar
$lastPrefixIndex = -1;
$stringValue = (string) $value;
- $shouldQuote = preg_match('/^[a-zA-z0-9]+$/', $stringValue) === 0;
+ $shouldQuote = preg_match('/^[a-zA-Z0-9]+$/', $stringValue) === 0;
$lineToAddVariations = [
$key . '=' . (is_string($value) ? self::prepareQuotedValue($value) : $value),
@@ -398,6 +400,22 @@ protected static function addSlashesExceptFor(string $value, array $except = [])
return $escaped;
}
+ /**
+ * Get the basic permission bits for an environment file.
+ */
+ protected static function fileMode(string $path): int
+ {
+ clearstatcache(true, $path);
+
+ $mode = @fileperms($path);
+
+ if ($mode === false) {
+ throw new RuntimeException("Unable to read permissions for environment file [{$path}].");
+ }
+
+ return $mode & 0777;
+ }
+
/**
* Flush all static state.
*/
diff --git a/src/support/src/Facades/Auth.php b/src/support/src/Facades/Auth.php
index 003db882e..31e569184 100644
--- a/src/support/src/Facades/Auth.php
+++ b/src/support/src/Facades/Auth.php
@@ -54,4 +54,6 @@ protected static function getFacadeAccessor(): string
{
return 'auth';
}
+
+ // REMOVED: Auth::routes() requires laravel/ui, which Hypervel does not integrate.
}
diff --git a/src/support/src/Facades/Bus.php b/src/support/src/Facades/Bus.php
index 8a1f6f4bd..a71ec7714 100644
--- a/src/support/src/Facades/Bus.php
+++ b/src/support/src/Facades/Bus.php
@@ -28,6 +28,7 @@
* @method static string|null resolveQueueFromQueueRoute(object $queueable)
* @method static \Hypervel\Support\Testing\Fakes\BusFake except(array|string $jobsToDispatch)
* @method static void assertDispatched(\Closure|string $command, callable|int|null $callback = null)
+ * @method static void assertDispatchedOnce(\Closure|string $command)
* @method static void assertDispatchedTimes(\Closure|string $command, int $times = 1)
* @method static void assertNotDispatched(\Closure|string $command, callable|null $callback = null)
* @method static void assertNothingDispatched()
diff --git a/src/support/src/Facades/Cookie.php b/src/support/src/Facades/Cookie.php
index 06bd3feba..d28c4b7a7 100644
--- a/src/support/src/Facades/Cookie.php
+++ b/src/support/src/Facades/Cookie.php
@@ -4,17 +4,21 @@
namespace Hypervel\Support\Facades;
+use UnitEnum;
+
+use function Hypervel\Support\enum_value;
+
/**
* @method static \Symfony\Component\HttpFoundation\Cookie make(\UnitEnum|string $name, string|null $value, int $minutes = 0, string|null $path = null, string|null $domain = null, bool|null $secure = null, bool $httpOnly = true, bool $raw = false, string|null $sameSite = null)
* @method static \Symfony\Component\HttpFoundation\Cookie forever(\UnitEnum|string $name, string $value, string|null $path = null, string|null $domain = null, bool|null $secure = null, bool $httpOnly = true, bool $raw = false, string|null $sameSite = null)
* @method static \Symfony\Component\HttpFoundation\Cookie forget(\UnitEnum|string $name, string|null $path = null, string|null $domain = null)
* @method static bool hasQueued(\UnitEnum|string $key, string|null $path = null)
- * @method static \Symfony\Component\HttpFoundation\Cookie|null queued(\UnitEnum|string $key, mixed $default = null, string|null $path = null)
+ * @method static ($default is null ? \Symfony\Component\HttpFoundation\Cookie|null : mixed) queued(\UnitEnum|string $key, mixed $default = null, string|null $path = null)
* @method static void queue(mixed ...$parameters)
* @method static void expire(\UnitEnum|string $name, string|null $path = null, string|null $domain = null)
* @method static void unqueue(\UnitEnum|string $name, string|null $path = null)
* @method static \Hypervel\Cookie\CookieJar setDefaultPathAndDomain(string $path, string|null $domain, bool|null $secure = false, string|null $sameSite = null)
- * @method static array getQueuedCookies()
+ * @method static \Symfony\Component\HttpFoundation\Cookie[] getQueuedCookies()
* @method static \Hypervel\Cookie\CookieJar flushQueuedCookies()
* @method static void macro(string $name, callable|object $macro)
* @method static void mixin(object $mixin, bool $replace = true)
@@ -28,17 +32,23 @@ class Cookie extends Facade
/**
* Determine if a cookie exists on the request.
*/
- public static function has(string $key): bool
+ public static function has(UnitEnum|string $key): bool
{
- return ! is_null(static::$app['request']->cookie($key, null));
+ $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key;
+
+ return ! is_null(static::$app['request']->cookie($key));
}
/**
* Retrieve a cookie from the request.
+ *
+ * @phpstan-return ($default is null ? string|array|null : mixed)
*/
- public static function get(?string $key = null, mixed $default = null): string|array|null
+ public static function get(UnitEnum|string|null $key = null, mixed $default = null): mixed
{
- return static::$app['request']->cookie($key, $default);
+ $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key;
+
+ return static::$app['request']->cookie($key) ?? $default;
}
/**
diff --git a/src/support/src/Facades/Crypt.php b/src/support/src/Facades/Crypt.php
index b811509f8..d3dca5c72 100644
--- a/src/support/src/Facades/Crypt.php
+++ b/src/support/src/Facades/Crypt.php
@@ -4,6 +4,9 @@
namespace Hypervel\Support\Facades;
+use RuntimeException;
+use SensitiveParameter;
+
/**
* @method static bool supported(string $key, string $cipher)
* @method static string generateKey(string $cipher)
@@ -25,4 +28,22 @@ protected static function getFacadeAccessor(): string
{
return 'encrypter';
}
+
+ /**
+ * Handle dynamic, static calls to the encrypter.
+ *
+ * @throws RuntimeException
+ */
+ public static function __callStatic(string $method, #[SensitiveParameter] array $args)
+ {
+ // This mirrors Facade::__callStatic() locally because delegating to the
+ // parent would retain the sensitive packed arguments in its stack frame.
+ $instance = static::getFacadeRoot();
+
+ if (! $instance) {
+ throw new RuntimeException('A facade root has not been set.');
+ }
+
+ return $instance->{$method}(...$args);
+ }
}
diff --git a/src/support/src/Facades/Facade.php b/src/support/src/Facades/Facade.php
index c83a3e1f2..82bdf1523 100644
--- a/src/support/src/Facades/Facade.php
+++ b/src/support/src/Facades/Facade.php
@@ -48,6 +48,9 @@ abstract class Facade
/**
* Run a Closure when the facade has been resolved.
+ *
+ * Boot-only. The callback is registered on the worker-global container
+ * and runs for every subsequent resolution of the facade accessor.
*/
public static function resolved(Closure $callback): void
{
diff --git a/src/support/src/Facades/Hash.php b/src/support/src/Facades/Hash.php
index ad570bddc..8209891ae 100644
--- a/src/support/src/Facades/Hash.php
+++ b/src/support/src/Facades/Hash.php
@@ -4,6 +4,9 @@
namespace Hypervel\Support\Facades;
+use RuntimeException;
+use SensitiveParameter;
+
/**
* @method static \Hypervel\Hashing\BcryptHasher createBcryptDriver()
* @method static \Hypervel\Hashing\ArgonHasher createArgonDriver()
@@ -11,7 +14,7 @@
* @method static array info(string $hashedValue)
* @method static string make(string $value, array $options = [])
* @method static bool check(string $value, string|null $hashedValue, array $options = [])
- * @method static bool needsRehash(string $hashedValue, array $options = [])
+ * @method static bool needsRehash(string|null $hashedValue, array $options = [])
* @method static bool isHashed(string $value)
* @method static string getDefaultDriver()
* @method static mixed driver(\UnitEnum|string|null $driver = null)
@@ -29,4 +32,22 @@ protected static function getFacadeAccessor(): string
{
return 'hash';
}
+
+ /**
+ * Handle dynamic, static calls to the hasher.
+ *
+ * @throws RuntimeException
+ */
+ public static function __callStatic(string $method, #[SensitiveParameter] array $args)
+ {
+ // This mirrors Facade::__callStatic() locally because delegating to the
+ // parent would retain the sensitive packed arguments in its stack frame.
+ $instance = static::getFacadeRoot();
+
+ if (! $instance) {
+ throw new RuntimeException('A facade root has not been set.');
+ }
+
+ return $instance->{$method}(...$args);
+ }
}
diff --git a/src/support/src/Facades/Mail.php b/src/support/src/Facades/Mail.php
index 7fd97e0d4..a586c9110 100644
--- a/src/support/src/Facades/Mail.php
+++ b/src/support/src/Facades/Mail.php
@@ -37,6 +37,7 @@
* @method static void assertNothingOutgoing()
* @method static void assertNothingSent()
* @method static void assertQueued(\Closure|string $mailable, callable|array|string|int|null $callback = null)
+ * @method static void assertQueuedTimes(string $mailable, int $times = 1)
* @method static void assertNotQueued(\Closure|string $mailable, callable|array|string|null $callback = null)
* @method static void assertNothingQueued()
* @method static void assertSentCount(int $count)
diff --git a/src/support/src/Facades/Queue.php b/src/support/src/Facades/Queue.php
index 2a5d160a3..e9d3cfa1f 100644
--- a/src/support/src/Facades/Queue.php
+++ b/src/support/src/Facades/Queue.php
@@ -64,6 +64,7 @@
* @method static \Hypervel\Queue\Queue setContainer(\Hypervel\Contracts\Container\Container $container)
* @method static \Hypervel\Support\Testing\Fakes\QueueFake except(array|string $jobsToBeQueued)
* @method static void assertPushed(\Closure|string $job, callable|int|null $callback = null)
+ * @method static void assertPushedOnce(string $job)
* @method static void assertPushedTimes(string $job, int $times = 1)
* @method static void assertPushedOn(string|null $queue, \Closure|string $job, callable|null $callback = null)
* @method static void assertPushedWithChain(string $job, array $expectedChain = [], callable|null $callback = null)
diff --git a/src/support/src/FileinfoMimeTypeGuesser.php b/src/support/src/FileinfoMimeTypeGuesser.php
index 2debe0d96..3653b9cd9 100644
--- a/src/support/src/FileinfoMimeTypeGuesser.php
+++ b/src/support/src/FileinfoMimeTypeGuesser.php
@@ -13,6 +13,7 @@
namespace Hypervel\Support;
+use ErrorException;
use Exception;
use finfo;
use Hypervel\Context\CoroutineContext;
@@ -60,7 +61,11 @@ public function guessMimeType(string $path): ?string
} catch (Exception $e) {
throw new RuntimeException($e->getMessage());
}
- $mimeType = $finfo->file($path) ?: null;
+ try {
+ $mimeType = $finfo->file($path) ?: null;
+ } catch (ErrorException) {
+ return null;
+ }
if ($mimeType && 0 === (strlen($mimeType) % 2)) {
$mimeStart = substr($mimeType, 0, strlen($mimeType) >> 1);
diff --git a/src/support/src/Js.php b/src/support/src/Js.php
index 617ddbea6..abfeb82a2 100644
--- a/src/support/src/Js.php
+++ b/src/support/src/Js.php
@@ -85,19 +85,21 @@ protected function convertDataToJavaScriptExpression(mixed $data, int $flags = 0
/**
* Encode the given data as JSON.
*
+ * Invalid UTF-8 sequences are replaced with � instead of throwing.
+ *
* @throws JsonException
*/
public static function encode(mixed $data, int $flags = 0, int $depth = 512): string
{
if ($data instanceof Jsonable) {
- return $data->toJson($flags | static::REQUIRED_FLAGS);
+ return $data->toJson($flags | static::REQUIRED_FLAGS | JSON_INVALID_UTF8_SUBSTITUTE);
}
if ($data instanceof Arrayable && ! ($data instanceof JsonSerializable)) {
$data = $data->toArray();
}
- return json_encode($data, $flags | static::REQUIRED_FLAGS, $depth);
+ return json_encode($data, $flags | static::REQUIRED_FLAGS | JSON_INVALID_UTF8_SUBSTITUTE, $depth);
}
/**
diff --git a/src/support/src/Lottery.php b/src/support/src/Lottery.php
index 4876b45b2..a065378b9 100644
--- a/src/support/src/Lottery.php
+++ b/src/support/src/Lottery.php
@@ -145,6 +145,9 @@ protected static function resultFactory(): callable
/**
* Force the lottery to always result in a win.
+ *
+ * Tests only. Without a callback, the result factory persists in static
+ * state for the worker lifetime and affects every subsequent lottery.
*/
public static function alwaysWin(?callable $callback = null): void
{
@@ -154,13 +157,18 @@ public static function alwaysWin(?callable $callback = null): void
return;
}
- $callback();
-
- static::determineResultNormally();
+ try {
+ $callback();
+ } finally {
+ static::determineResultNormally();
+ }
}
/**
* Force the lottery to always result in a lose.
+ *
+ * Tests only. Without a callback, the result factory persists in static
+ * state for the worker lifetime and affects every subsequent lottery.
*/
public static function alwaysLose(?callable $callback = null): void
{
@@ -170,13 +178,18 @@ public static function alwaysLose(?callable $callback = null): void
return;
}
- $callback();
-
- static::determineResultNormally();
+ try {
+ $callback();
+ } finally {
+ static::determineResultNormally();
+ }
}
/**
* Set the sequence that will be used to determine lottery results.
+ *
+ * Tests only. The sequence factory persists in a static property for the
+ * worker lifetime and affects every subsequent lottery result.
*/
public static function fix(array $sequence, ?callable $whenMissing = null): void
{
@@ -198,9 +211,11 @@ public static function forceResultWithSequence(array $sequence, ?callable $whenM
static::$resultFactory = null;
- $result = static::resultFactory()($chances, $outOf);
-
- static::$resultFactory = $factoryCache;
+ try {
+ $result = static::resultFactory()($chances, $outOf);
+ } finally {
+ static::$resultFactory = $factoryCache;
+ }
++$next;
diff --git a/src/support/src/Number.php b/src/support/src/Number.php
index 873ce336c..33070d0cc 100644
--- a/src/support/src/Number.php
+++ b/src/support/src/Number.php
@@ -6,6 +6,7 @@
use Hypervel\Context\CoroutineContext;
use Hypervel\Support\Traits\Macroable;
+use InvalidArgumentException;
use NumberFormatter;
use RuntimeException;
@@ -164,9 +165,15 @@ public static function currency(float|int $number, string $in = '', ?string $loc
*/
public static function fileSize(float|int $bytes, int $precision = 0, ?int $maxPrecision = null): string
{
+ if (! is_finite($bytes)) {
+ return sprintf('%s B', static::format($bytes, $precision, $maxPrecision));
+ }
+
$units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
- for ($i = 0; ($bytes / 1024) > 0.9 && ($i < count($units) - 1); ++$i) {
+ $unitCount = count($units);
+
+ for ($i = 0; (abs($bytes) / 1024) > 0.9 && ($i < $unitCount - 1); ++$i) {
$bytes /= 1024;
}
@@ -203,9 +210,15 @@ public static function forHumans(float|int $number, int $precision = 0, ?int $ma
/**
* Convert the number to its human-readable equivalent.
+ *
+ * @phpstan-return ($number is INF ? '∞' : ($number is NAN ? 'NaN' : ($number is 0 ? ($precision is non-positive-int ? '0' : non-empty-string|false) : non-empty-string|false)))
*/
protected static function summarize(float|int $number, int $precision = 0, ?int $maxPrecision = null, array $units = []): false|string
{
+ if (! is_finite($number)) {
+ return static::format($number, $precision, $maxPrecision);
+ }
+
if (empty($units)) {
$units = [
3 => 'K',
@@ -217,7 +230,7 @@ protected static function summarize(float|int $number, int $precision = 0, ?int
}
switch (true) {
- case floatval($number) === 0.0:
+ case (float) $number === 0.0:
return $precision > 0 ? static::format(0, $precision, $maxPrecision) : '0';
case $number < 0:
return sprintf('-%s', static::summarize(abs($number), $precision, $maxPrecision, $units));
@@ -227,9 +240,17 @@ protected static function summarize(float|int $number, int $precision = 0, ?int
$numberExponent = (int) floor(log10($number));
$displayExponent = $numberExponent - ($numberExponent % 3);
- $number /= 10 ** $displayExponent;
+ $number /= pow(10, $displayExponent);
+
+ $formatted = static::format($number, $precision, $maxPrecision);
- return trim(sprintf('%s%s', static::format($number, $precision, $maxPrecision), $units[$displayExponent] ?? ''));
+ if (static::parseFloat($formatted) >= 1000 && isset($units[$displayExponent + 3])) {
+ $number /= 1000;
+ $displayExponent += 3;
+ $formatted = static::format($number, $precision, $maxPrecision);
+ }
+
+ return trim(sprintf('%s%s', $formatted, $units[$displayExponent] ?? ''));
}
/**
@@ -245,6 +266,12 @@ public static function clamp(float|int $number, float|int $min, float|int $max):
*/
public static function pairs(float|int $to, float|int $by, float|int $start = 0, float|int $offset = 1): array
{
+ if ($by === 0 || $by === 0.0) {
+ throw new InvalidArgumentException('The $by argument must not be zero.');
+ }
+
+ $by = abs($by);
+
$output = [];
for ($lower = $start; $lower < $to; $lower += $by) {
@@ -265,6 +292,10 @@ public static function pairs(float|int $to, float|int $by, float|int $start = 0,
*/
public static function trim(float|int $number): float|int
{
+ if (is_infinite($number) || is_nan($number)) {
+ return $number;
+ }
+
return json_decode(json_encode($number));
}
diff --git a/src/support/src/Once.php b/src/support/src/Once.php
index 849bc7a98..cb79374a0 100644
--- a/src/support/src/Once.php
+++ b/src/support/src/Once.php
@@ -83,13 +83,4 @@ public static function flush(): void
{
CoroutineContext::forget(self::INSTANCE_CONTEXT_KEY);
}
-
- /**
- * Flush all static state.
- */
- public static function flushState(): void
- {
- static::flush();
- static::enable();
- }
}
diff --git a/src/support/src/ServiceProvider.php b/src/support/src/ServiceProvider.php
index 0ee03df86..9cce7b582 100644
--- a/src/support/src/ServiceProvider.php
+++ b/src/support/src/ServiceProvider.php
@@ -13,8 +13,10 @@
use Hypervel\Database\Migrations\Migrator;
use Hypervel\Di\Aop\AspectCollector;
use Hypervel\Di\ClassMap\ClassMapManager;
+use Hypervel\Filesystem\Filesystem;
use Hypervel\View\Compilers\CompilerInterface;
use ReflectionProperty;
+use RuntimeException;
abstract class ServiceProvider
{
@@ -284,6 +286,8 @@ protected function loadMigrationsFrom(array|string $paths): void
});
}
+ // REMOVED: Laravel's deprecated loadFactoriesFrom() method is intentionally omitted.
+
/**
* Setup an after resolving listener, or fire immediately if already resolved.
*/
@@ -357,11 +361,10 @@ protected function addPublishGroup(string $group, array $paths): void
*/
public static function pathsToPublish(?string $provider = null, ?string $group = null): array
{
- if (! is_null($paths = static::pathsForProviderOrGroup($provider, $group))) { // @phpstan-ignore function.impossibleType (logic bug: method always returns array, fix in separate PR)
+ if (! is_null($paths = static::pathsForProviderOrGroup($provider, $group))) {
return $paths;
}
- // @phpstan-ignore deadCode.unreachable (logic bug: method always returns array, fix in separate PR)
return collect(static::$publishes)->reduce(function ($paths, $p) {
return array_merge($paths, $p);
}, []);
@@ -437,6 +440,8 @@ public static function addProviderToBootstrapFile(string $provider, ?string $pat
return false;
}
+ $path = realpath($path) ?: $path;
+
if (function_exists('opcache_invalidate')) {
opcache_invalidate($path, true);
}
@@ -455,7 +460,7 @@ public static function addProviderToBootstrapFile(string $provider, ?string $pat
' . $providers . '
];';
- file_put_contents($path, $content . PHP_EOL);
+ static::replaceBootstrapProviderFile($path, $content . PHP_EOL);
return true;
}
@@ -473,6 +478,8 @@ public static function removeProviderFromBootstrapFile(string|array $providersTo
return false;
}
+ $path = realpath($path) ?: $path;
+
if (function_exists('opcache_invalidate')) {
opcache_invalidate($path, true);
}
@@ -497,7 +504,7 @@ public static function removeProviderFromBootstrapFile(string|array $providersTo
' . $providers . '
];';
- file_put_contents($path, $content . PHP_EOL);
+ static::replaceBootstrapProviderFile($path, $content . PHP_EOL);
return true;
}
@@ -603,6 +610,28 @@ protected function classMap(array $map): void
ClassMapManager::add($map);
}
+ /**
+ * Replace the bootstrap provider file without exposing partial contents.
+ */
+ protected static function replaceBootstrapProviderFile(string $path, string $content): void
+ {
+ clearstatcache(true, $path);
+
+ $mode = @fileperms($path);
+
+ if ($mode === false) {
+ throw new RuntimeException("Unable to read permissions for bootstrap provider file [{$path}].");
+ }
+
+ (new Filesystem)->replace($path, $content, $mode & 0777);
+
+ if (function_exists('opcache_invalidate')) {
+ opcache_invalidate($path, true);
+ }
+ }
+
+ // REMOVED: Laravel's deferred-provider metadata APIs do not fit Hypervel's worker bootstrap model.
+
/**
* Get the default providers for a Hypervel application.
*/
diff --git a/src/support/src/Str.php b/src/support/src/Str.php
index a6feb66fd..c7321dd8b 100644
--- a/src/support/src/Str.php
+++ b/src/support/src/Str.php
@@ -60,6 +60,14 @@ public static function of(mixed $string): Stringable
return new Stringable($string);
}
+ /**
+ * Translate the given message and get a new stringable object.
+ */
+ public static function trans(string $key, array $replace = [], ?string $locale = null): Stringable
+ {
+ return new Stringable(__($key, $replace, $locale));
+ }
+
/**
* Return the remainder of a string after the first occurrence of a given value.
*/
@@ -284,6 +292,14 @@ public static function convertCase(string $string, int $mode = MB_CASE_FOLD, ?st
return mb_convert_case($string, $mode, $encoding);
}
+ /**
+ * Get the plural form of an English word with the count prepended.
+ */
+ public static function counted(string $value, int|array|Countable $count): string
+ {
+ return static::plural($value, $count, prependCount: true);
+ }
+
/**
* Replace consecutive instances of a given character with a single character in the given string.
*
@@ -919,9 +935,11 @@ public static function createRandomStringsUsingSequence(array $sequence, ?callab
static::$randomStringFactory = null;
- $randomString = static::random($length);
-
- static::$randomStringFactory = $factoryCache;
+ try {
+ $randomString = static::random($length);
+ } finally {
+ static::$randomStringFactory = $factoryCache;
+ }
++$next;
@@ -1638,9 +1656,11 @@ public static function createUuidsUsingSequence(array $sequence, ?callable $when
static::$uuidFactory = null;
- $uuid = static::uuid();
-
- static::$uuidFactory = $factoryCache;
+ try {
+ $uuid = static::uuid();
+ } finally {
+ static::$uuidFactory = $factoryCache;
+ }
++$next;
@@ -1751,9 +1771,11 @@ public static function createUlidsUsingSequence(array $sequence, ?callable $when
static::$ulidFactory = null;
- $ulid = static::ulid();
-
- static::$ulidFactory = $factoryCache;
+ try {
+ $ulid = static::ulid();
+ } finally {
+ static::$ulidFactory = $factoryCache;
+ }
++$next;
diff --git a/src/support/src/Stringable.php b/src/support/src/Stringable.php
index f792c533f..520505247 100644
--- a/src/support/src/Stringable.php
+++ b/src/support/src/Stringable.php
@@ -193,6 +193,14 @@ public function convertCase(int $mode = MB_CASE_FOLD, ?string $encoding = 'UTF-8
return new static(Str::convertCase($this->value, $mode, $encoding));
}
+ /**
+ * Get the plural form of an English word with the count prepended.
+ */
+ public function counted(int|array|Countable $count): static
+ {
+ return new static(Str::counted($this->value, $count));
+ }
+
/**
* Replace consecutive instances of a given character with a single character.
*/
diff --git a/src/support/src/Testing/Fakes/BusFake.php b/src/support/src/Testing/Fakes/BusFake.php
index 20baf3d79..3620edcb9 100644
--- a/src/support/src/Testing/Fakes/BusFake.php
+++ b/src/support/src/Testing/Fakes/BusFake.php
@@ -13,6 +13,7 @@
use Hypervel\Foundation\Bus\PendingChain;
use Hypervel\Support\Arr;
use Hypervel\Support\Collection;
+use Hypervel\Support\Str;
use Hypervel\Support\Traits\ReflectsClosures;
use PHPUnit\Framework\Assert as PHPUnit;
use RuntimeException;
@@ -107,6 +108,14 @@ public function assertDispatched(Closure|string $command, callable|int|null $cal
);
}
+ /**
+ * Assert if a job was pushed exactly once.
+ */
+ public function assertDispatchedOnce(Closure|string $command): void
+ {
+ $this->assertDispatchedTimes($command, 1);
+ }
+
/**
* Assert if a job was pushed a number of times.
*/
@@ -125,7 +134,11 @@ public function assertDispatchedTimes(Closure|string $command, int $times = 1):
PHPUnit::assertSame(
$times,
$count,
- "The expected [{$command}] job was pushed {$count} times instead of {$times} times."
+ sprintf(
+ "The expected [{$command}] job was pushed {$count} %s instead of {$times} %s.",
+ Str::plural('time', $count),
+ Str::plural('time', $times)
+ )
);
}
@@ -192,7 +205,11 @@ public function assertDispatchedSyncTimes(Closure|string $command, int $times =
PHPUnit::assertSame(
$times,
$count,
- "The expected [{$command}] job was synchronously pushed {$count} times instead of {$times} times."
+ sprintf(
+ "The expected [{$command}] job was synchronously pushed {$count} %s instead of {$times} %s.",
+ Str::plural('time', $count),
+ Str::plural('time', $times)
+ )
);
}
@@ -248,7 +265,11 @@ public function assertDispatchedAfterResponseTimes(Closure|string $command, int
PHPUnit::assertSame(
$times,
$count,
- "The expected [{$command}] job was pushed {$count} times instead of {$times} times."
+ sprintf(
+ "The expected [{$command}] job was pushed {$count} %s instead of {$times} %s.",
+ Str::plural('time', $count),
+ Str::plural('time', $times)
+ )
);
}
diff --git a/src/support/src/Testing/Fakes/ExceptionHandlerFake.php b/src/support/src/Testing/Fakes/ExceptionHandlerFake.php
index 12e0f80e1..7a58afd82 100644
--- a/src/support/src/Testing/Fakes/ExceptionHandlerFake.php
+++ b/src/support/src/Testing/Fakes/ExceptionHandlerFake.php
@@ -6,6 +6,7 @@
use Closure;
use Hypervel\Contracts\Debug\ExceptionHandler;
+use Hypervel\Foundation\Testing\Concerns\WithoutExceptionHandlingHandler;
use Hypervel\Http\Request;
use Hypervel\Support\Collection;
use Hypervel\Support\Traits\ForwardsCalls;
@@ -167,7 +168,15 @@ protected function isFakedException(Throwable $e): bool
*/
public function shouldReport(Throwable $e): bool
{
- return $this->handler->shouldReport($e);
+ return $this->runningWithoutExceptionHandling() || $this->handler->shouldReport($e);
+ }
+
+ /**
+ * Determine if the handler is running without exception handling.
+ */
+ protected function runningWithoutExceptionHandling(): bool
+ {
+ return $this->handler instanceof WithoutExceptionHandlingHandler;
}
/**
diff --git a/src/support/src/Testing/Fakes/MailFake.php b/src/support/src/Testing/Fakes/MailFake.php
index 7651ad5e5..fe00b46b6 100644
--- a/src/support/src/Testing/Fakes/MailFake.php
+++ b/src/support/src/Testing/Fakes/MailFake.php
@@ -197,7 +197,7 @@ public function assertQueued(Closure|string $mailable, array|callable|int|string
/**
* Assert if a mailable was queued a number of times.
*/
- protected function assertQueuedTimes(string $mailable, int $times = 1): void
+ public function assertQueuedTimes(string $mailable, int $times = 1): void
{
$count = $this->queued($mailable)->count();
diff --git a/src/support/src/Testing/Fakes/PendingBatchFake.php b/src/support/src/Testing/Fakes/PendingBatchFake.php
index d44057d0a..a0a35e86d 100644
--- a/src/support/src/Testing/Fakes/PendingBatchFake.php
+++ b/src/support/src/Testing/Fakes/PendingBatchFake.php
@@ -4,12 +4,16 @@
namespace Hypervel\Support\Testing\Fakes;
+use Closure;
use Hypervel\Bus\Batch;
use Hypervel\Bus\PendingBatch;
use Hypervel\Support\Collection;
+use Hypervel\Support\Traits\ReflectsClosures;
class PendingBatchFake extends PendingBatch
{
+ use ReflectsClosures;
+
/**
* Create a new pending batch instance.
*
@@ -36,4 +40,36 @@ public function dispatchAfterResponse(): Batch
{
return $this->bus->recordPendingBatch($this);
}
+
+ /**
+ * Determine if the jobs in the batch match the given jobs.
+ */
+ public function hasJobs(array $expectedJobs): bool
+ {
+ if (count($this->jobs) !== count($expectedJobs)) {
+ return false;
+ }
+
+ foreach ($expectedJobs as $index => $expectedJob) {
+ if ($expectedJob instanceof Closure) {
+ $expectedType = $this->firstClosureParameterType($expectedJob);
+
+ if (! $this->jobs[$index] instanceof $expectedType) {
+ return false;
+ }
+
+ if (! $expectedJob($this->jobs[$index])) {
+ return false;
+ }
+ } elseif (is_string($expectedJob)) {
+ if ($expectedJob !== get_class($this->jobs[$index])) {
+ return false;
+ }
+ } elseif (serialize($expectedJob) !== serialize($this->jobs[$index])) {
+ return false;
+ }
+ }
+
+ return true;
+ }
}
diff --git a/src/support/src/Testing/Fakes/PendingMailFake.php b/src/support/src/Testing/Fakes/PendingMailFake.php
index 59b0cc770..9fc99717a 100644
--- a/src/support/src/Testing/Fakes/PendingMailFake.php
+++ b/src/support/src/Testing/Fakes/PendingMailFake.php
@@ -25,6 +25,6 @@ public function send(Mailable $mailable): ?SentMessage
*/
public function sendNow(Mailable $mailable): ?SentMessage
{
- return $this->send($mailable);
+ return $this->mailer->sendNow($this->fill($mailable));
}
}
diff --git a/src/support/src/Testing/Fakes/QueueFake.php b/src/support/src/Testing/Fakes/QueueFake.php
index f755855bb..89807d764 100644
--- a/src/support/src/Testing/Fakes/QueueFake.php
+++ b/src/support/src/Testing/Fakes/QueueFake.php
@@ -127,6 +127,14 @@ public function assertPushedTimes(string $job, int $times = 1): void
);
}
+ /**
+ * Assert if a job was pushed exactly once.
+ */
+ public function assertPushedOnce(string $job): void
+ {
+ $this->assertPushedTimes($job, 1);
+ }
+
/**
* Assert if a job was pushed based on a truth-test callback.
*/
diff --git a/src/support/src/ValidatedInput.php b/src/support/src/ValidatedInput.php
index 8fc6c341c..8ee7ebd72 100644
--- a/src/support/src/ValidatedInput.php
+++ b/src/support/src/ValidatedInput.php
@@ -6,6 +6,7 @@
use ArrayIterator;
use Hypervel\Contracts\Support\ValidatedData;
+use Hypervel\Http\UploadedFile;
use Hypervel\Support\Traits\InteractsWithData;
use Symfony\Component\VarDumper\VarDumper;
use Traversable;
@@ -80,6 +81,16 @@ public function input(?string $key = null, mixed $default = null): mixed
);
}
+ /**
+ * Retrieve a file from the validated inputs.
+ */
+ public function file(string $key, mixed $default = null): mixed
+ {
+ $value = $this->input($key, $default);
+
+ return $value instanceof UploadedFile ? $value : $default;
+ }
+
/**
* Dump the validated inputs items and end the script.
*
diff --git a/src/support/src/Xml.php b/src/support/src/Xml.php
index 0d5e39e99..749fba50a 100644
--- a/src/support/src/Xml.php
+++ b/src/support/src/Xml.php
@@ -30,14 +30,12 @@ public static function toXml(mixed $data, ?SimpleXMLElement $parentNode = null,
$xml = $parentNode;
}
foreach ($data as $key => $value) {
+ $key = is_numeric($key) ? 'item' . $key : $key;
+
if (is_array($value)) {
self::toXml($value, $xml->addChild($key));
} else {
- if (is_numeric($key)) {
- $xml->addChild('item' . $key, (string) $value);
- } else {
- $xml->addChild($key, (string) $value);
- }
+ $xml->addChild($key, (string) $value);
}
}
return trim($xml->asXML());
diff --git a/src/testbench/src/Foundation/Application.php b/src/testbench/src/Foundation/Application.php
index 9d977254e..907700d4a 100644
--- a/src/testbench/src/Foundation/Application.php
+++ b/src/testbench/src/Foundation/Application.php
@@ -30,7 +30,6 @@
use Hypervel\Routing\Middleware\ThrottleRequests;
use Hypervel\Support\Arr;
use Hypervel\Support\EncodedHtmlString;
-use Hypervel\Support\Once;
use Hypervel\Support\Sleep;
use Hypervel\Support\Str;
use Hypervel\Testbench\Bootstrap\RegisterProviders;
@@ -390,7 +389,6 @@ public static function flushState(object $instance): void
Markdown::flushState();
Migrator::flushState();
Model::flushState();
- Once::flushState();
PreventRequestForgery::flushState();
PreventRequestsDuringMaintenance::flushState();
Queue::flushState();
diff --git a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php
index 0b7b0f717..214e9dbca 100644
--- a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php
+++ b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php
@@ -111,6 +111,13 @@ protected function flushFrameworkState(): void
\Carbon\Carbon::serializeUsing(null);
\Carbon\Carbon::setTestNow();
\Carbon\CarbonImmutable::setTestNow();
+
+ if (class_exists(\Laravel\SerializableClosure\SerializableClosure::class)) {
+ \Laravel\SerializableClosure\SerializableClosure::setSecretKey(null);
+ \Laravel\SerializableClosure\SerializableClosure::transformUseVariablesUsing(null);
+ \Laravel\SerializableClosure\SerializableClosure::resolveUseVariablesUsing(null);
+ }
+
\Hypervel\Auth\Access\Gate::flushState();
\Hypervel\Auth\AuthenticationException::flushState();
\Hypervel\Auth\EloquentUserProvider::flushState();
@@ -171,6 +178,7 @@ protected function flushFrameworkState(): void
\Hypervel\Di\Aop\AspectManager::flushState();
\Hypervel\Di\Aop\AstVisitorRegistry::flushState();
\Hypervel\Di\ClassMap\ClassMapManager::flushState();
+ \Hypervel\Encryption\Commands\KeyGenerateCommand::flushState();
\Hypervel\Events\Dispatcher::flushState();
\Hypervel\Filesystem\Filesystem::flushState();
\Hypervel\Filesystem\FilesystemAdapter::flushState();
@@ -274,7 +282,6 @@ protected function flushFrameworkState(): void
\Hypervel\Support\LazyCollection::flushState();
\Hypervel\Support\Lottery::flushState();
\Hypervel\Support\Number::flushState();
- \Hypervel\Support\Once::flushState();
\Hypervel\Support\Optional::flushState();
\Hypervel\Support\Pluralizer::flushState();
\Hypervel\Support\ServiceProvider::flushState();
diff --git a/tests/Auth/TimeboxIsolationTest.php b/tests/Auth/TimeboxIsolationTest.php
new file mode 100644
index 000000000..64b77c1c0
--- /dev/null
+++ b/tests/Auth/TimeboxIsolationTest.php
@@ -0,0 +1,158 @@
+shouldReceive('retrieveByCredentials')->twice()->andReturn($user, null);
+ $provider->shouldReceive('validateCredentials')->once()->with($user, ['email' => 'test@example.com'])->andReturn(true);
+
+ if ($operation !== 'validate') {
+ $provider->shouldReceive('rehashPasswordIfRequired')->once()->with($user, ['email' => 'test@example.com']);
+ }
+
+ $guard = new TrackingSessionGuard(
+ 'web',
+ $provider,
+ m::mock(Session::class),
+ m::mock(Container::class),
+ $timebox,
+ timeboxDuration: 1_000_000,
+ );
+
+ $this->assertTrue($guard->{$operation}(['email' => 'test@example.com']));
+ $this->assertFalse($guard->{$operation}(['email' => 'test@example.com']));
+ $this->assertSame([0, 0], $recorder->startingOperationCounts);
+ $this->assertSame(1, $recorder->sleeps);
+ $this->assertSame(0, $timebox->operationCount);
+ $this->assertFalse($timebox->earlyReturn);
+ }
+
+ public static function guardOperationProvider(): array
+ {
+ return [
+ ['validate'],
+ ['attempt'],
+ ['attemptWhen'],
+ ];
+ }
+
+ public function testSendingResetLinksUsesIndependentTimeboxes(): void
+ {
+ $recorder = new TimeboxOperationRecorder;
+ $timebox = new TrackingTimebox($recorder);
+ $users = m::mock(UserProvider::class);
+ $users->shouldReceive('retrieveByCredentials')->twice()->with(['email' => 'missing@example.com'])->andReturnNull();
+
+ $broker = new PasswordBroker(
+ m::mock(TokenRepositoryInterface::class),
+ $users,
+ 'users',
+ timebox: $timebox,
+ timeboxDuration: 1_000_000,
+ );
+
+ $this->assertSame(PasswordBrokerContract::INVALID_USER, $broker->sendResetLink(['email' => 'missing@example.com']));
+ $this->assertSame(PasswordBrokerContract::INVALID_USER, $broker->sendResetLink(['email' => 'missing@example.com']));
+ $this->assertSame([0, 0], $recorder->startingOperationCounts);
+ $this->assertSame(2, $recorder->sleeps);
+ $this->assertSame(0, $timebox->operationCount);
+ }
+
+ public function testPasswordResetsUseIndependentTimeboxes(): void
+ {
+ $recorder = new TimeboxOperationRecorder;
+ $timebox = new TrackingTimebox($recorder);
+ $tokens = m::mock(TokenRepositoryInterface::class);
+ $users = m::mock(UserProvider::class);
+ $user = m::mock(Authenticatable::class . ',' . CanResetPassword::class);
+ $credentials = [
+ 'email' => 'test@example.com',
+ 'password' => 'secret',
+ 'token' => 'token',
+ ];
+
+ $users->shouldReceive('retrieveByCredentials')
+ ->twice()
+ ->with(['email' => 'test@example.com', 'password' => 'secret'])
+ ->andReturn($user, null);
+ $tokens->shouldReceive('exists')->once()->with($user, 'token')->andReturnTrue();
+ $tokens->shouldReceive('delete')->once()->with($user);
+
+ $broker = new PasswordBroker(
+ $tokens,
+ $users,
+ 'users',
+ timebox: $timebox,
+ timeboxDuration: 1_000_000,
+ );
+
+ $this->assertSame(PasswordBrokerContract::PASSWORD_RESET, $broker->reset($credentials, static function (): void {
+ }));
+ $this->assertSame(PasswordBrokerContract::INVALID_USER, $broker->reset($credentials, static function (): void {
+ }));
+ $this->assertSame([0, 0], $recorder->startingOperationCounts);
+ $this->assertSame(1, $recorder->sleeps);
+ $this->assertSame(0, $timebox->operationCount);
+ $this->assertFalse($timebox->earlyReturn);
+ }
+}
+
+class TrackingSessionGuard extends SessionGuard
+{
+ public function login(Authenticatable $user, bool $remember = false): void
+ {
+ }
+}
+
+class TrackingTimebox extends Timebox
+{
+ public int $operationCount = 0;
+
+ public function __construct(private TimeboxOperationRecorder $recorder)
+ {
+ }
+
+ public function call(callable $callback, int $microseconds): mixed
+ {
+ $this->recorder->startingOperationCounts[] = $this->operationCount++;
+
+ return parent::call($callback, $microseconds);
+ }
+
+ protected function usleep(int $microseconds): void
+ {
+ ++$this->recorder->sleeps;
+ }
+}
+
+class TimeboxOperationRecorder
+{
+ public array $startingOperationCounts = [];
+
+ public int $sleeps = 0;
+}
diff --git a/tests/Cookie/CookieFacadeTest.php b/tests/Cookie/CookieFacadeTest.php
new file mode 100644
index 000000000..310e6d582
--- /dev/null
+++ b/tests/Cookie/CookieFacadeTest.php
@@ -0,0 +1,73 @@
+instance('request', Request::create('/', 'GET', [], [
+ 'session_id' => 'session-value',
+ 'theme' => 'dark',
+ '0' => 'zero-value',
+ ]));
+
+ Cookie::setFacadeApplication($app);
+ }
+
+ public function testHasAcceptsEnumCookieNames(): void
+ {
+ $this->assertTrue(Cookie::has(CookieFacadeTestStringName::Session));
+ $this->assertTrue(Cookie::has(CookieFacadeTestUnitName::theme));
+ $this->assertTrue(Cookie::has(CookieFacadeTestIntegerName::Zero));
+ }
+
+ public function testGetAcceptsEnumCookieNames(): void
+ {
+ $this->assertSame('session-value', Cookie::get(CookieFacadeTestStringName::Session));
+ $this->assertSame('dark', Cookie::get(CookieFacadeTestUnitName::theme));
+ $this->assertSame('zero-value', Cookie::get(CookieFacadeTestIntegerName::Zero));
+ }
+
+ public function testGetReturnsMixedDefaultsWithoutChangingAllCookieReads(): void
+ {
+ $default = new stdClass;
+
+ $this->assertSame($default, Cookie::get('missing', $default));
+ $this->assertSame('session-value', Cookie::get('session_id', $default));
+ $this->assertSame([
+ 'session_id' => 'session-value',
+ 'theme' => 'dark',
+ 0 => 'zero-value',
+ ], Cookie::get());
+ }
+}
diff --git a/tests/Cookie/CookieJarTest.php b/tests/Cookie/CookieJarTest.php
index 4849bf3ca..7315825b3 100644
--- a/tests/Cookie/CookieJarTest.php
+++ b/tests/Cookie/CookieJarTest.php
@@ -12,6 +12,7 @@
use InvalidArgumentException;
use Mockery as m;
use PHPUnit\Framework\Attributes\TestWith;
+use stdClass;
use Symfony\Component\HttpFoundation\Cookie;
enum CookieJarTestNameEnum: string
@@ -332,6 +333,25 @@ public function testGet()
$this->assertEquals('bar', $manager->get('foo'));
}
+ public function testGetReturnsNestedCookieValuesAndArrayDefaults(): void
+ {
+ RequestContext::set(Request::create('/', 'GET', [], [
+ 'preferences' => ['theme' => 'dark'],
+ ]));
+
+ $manager = new CookieJar;
+
+ $this->assertSame(['theme' => 'dark'], $manager->get('preferences'));
+ $this->assertSame(['theme' => 'light'], $manager->get('missing', ['theme' => 'light']));
+ }
+
+ public function testQueuedReturnsMixedDefaults(): void
+ {
+ $default = new stdClass;
+
+ $this->assertSame($default, (new CookieJar)->queued('missing', $default));
+ }
+
// =========================================================================
// Hypervel-specific: forever duration
// =========================================================================
diff --git a/tests/Encryption/CryptFacadeTest.php b/tests/Encryption/CryptFacadeTest.php
new file mode 100644
index 000000000..3711786bf
--- /dev/null
+++ b/tests/Encryption/CryptFacadeTest.php
@@ -0,0 +1,46 @@
+shouldReceive('probe')
+ ->once()
+ ->with('value', 42)
+ ->andReturn('result');
+
+ $app = new Container;
+ $app->instance('encrypter', $root);
+
+ Crypt::setFacadeApplication($app);
+
+ $this->assertSame('result', Crypt::probe('value', 42));
+ }
+
+ public function testDynamicCallsWithoutAFacadeRootThrowTheBaseException(): void
+ {
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('A facade root has not been set.');
+
+ Crypt::probe();
+ }
+}
diff --git a/tests/Encryption/EncrypterTest.php b/tests/Encryption/EncrypterTest.php
index 0b205d001..71c4d8ece 100644
--- a/tests/Encryption/EncrypterTest.php
+++ b/tests/Encryption/EncrypterTest.php
@@ -7,6 +7,7 @@
use Hypervel\Contracts\Encryption\DecryptException;
use Hypervel\Encryption\Encrypter;
use Hypervel\Tests\TestCase;
+use Override;
use PHPUnit\Framework\Attributes\DataProvider;
use RuntimeException;
@@ -64,6 +65,23 @@ public function testItValidatesMacOnPerKeyBasis()
$this->assertSame('foo', $new->decryptString($encrypted));
}
+ public function testItValidatesEveryMacBeforeDecryptingWithTheFirstValidKey(): void
+ {
+ $currentKey = str_repeat('a', 16);
+ $validPreviousKey = str_repeat('b', 16);
+ $invalidPreviousKey = str_repeat('c', 16);
+ $encrypted = (new Encrypter($validPreviousKey))->encryptString('foo');
+
+ $encrypter = new TrackingEncrypter($currentKey);
+ $encrypter->previousKeys([$validPreviousKey, $invalidPreviousKey]);
+
+ $this->assertSame('foo', $encrypter->decryptString($encrypted));
+ $this->assertSame(
+ [$currentKey, $validPreviousKey, $invalidPreviousKey],
+ $encrypter->checkedKeys
+ );
+ }
+
public function testEncryptionUsingBase64EncodedKey()
{
$e = new Encrypter(random_bytes(16));
@@ -132,6 +150,30 @@ public function testThatAnAeadTagMustBeProvidedInFullLength()
$e->decrypt($encrypted);
}
+ public function testThatAnAeadTagMustBePresent(): void
+ {
+ $encrypter = new Encrypter(str_repeat('b', 32), 'AES-256-GCM');
+ $payload = json_decode(base64_decode($encrypter->encrypt('foo')), true);
+ unset($payload['tag']);
+
+ $this->expectException(DecryptException::class);
+ $this->expectExceptionMessage('Could not decrypt the data.');
+
+ $encrypter->decrypt(base64_encode(json_encode($payload)));
+ }
+
+ public function testThatAnAeadTagMustNotBeEmpty(): void
+ {
+ $encrypter = new Encrypter(str_repeat('b', 32), 'AES-256-GCM');
+ $payload = json_decode(base64_decode($encrypter->encrypt('foo')), true);
+ $payload['tag'] = '';
+
+ $this->expectException(DecryptException::class);
+ $this->expectExceptionMessage('Could not decrypt the data.');
+
+ $encrypter->decrypt(base64_encode(json_encode($payload)));
+ }
+
public function testThatAnAeadTagCantBeModified()
{
$e = new Encrypter(str_repeat('b', 32), 'AES-256-GCM');
@@ -300,3 +342,24 @@ public function testEncryptedReturnsFalseForNonString()
$this->assertFalse(Encrypter::appearsEncrypted(null));
}
}
+
+class TrackingEncrypter extends Encrypter
+{
+ /**
+ * The keys checked while validating a payload.
+ *
+ * @var list
+ */
+ public array $checkedKeys = [];
+
+ /**
+ * Determine if the MAC is valid for the given payload and key.
+ */
+ #[Override]
+ protected function validMacForKey(array $payload, string $key): bool
+ {
+ $this->checkedKeys[] = $key;
+
+ return parent::validMacForKey($payload, $key);
+ }
+}
diff --git a/tests/Encryption/SensitiveParameterTest.php b/tests/Encryption/SensitiveParameterTest.php
new file mode 100644
index 000000000..6946a0a29
--- /dev/null
+++ b/tests/Encryption/SensitiveParameterTest.php
@@ -0,0 +1,79 @@
+getParameters()[0];
+
+ $this->assertSame('value', $parameter->getName());
+ $this->assertCount(1, $parameter->getAttributes(SensitiveParameter::class));
+ }
+
+ #[DataProvider('sensitiveParameters')]
+ public function testSecretBearingParametersAreRedacted(
+ string $class,
+ string $method,
+ string $parameterName
+ ): void {
+ $parameters = (new ReflectionMethod($class, $method))->getParameters();
+ $parameter = null;
+
+ foreach ($parameters as $candidate) {
+ if ($candidate->getName() === $parameterName) {
+ $parameter = $candidate;
+
+ break;
+ }
+ }
+
+ $this->assertNotNull($parameter);
+ $this->assertCount(1, $parameter->getAttributes(SensitiveParameter::class));
+ }
+
+ /**
+ * Provide every named method parameter that can carry plaintext or key material.
+ *
+ * @return array
+ */
+ public static function sensitiveParameters(): array
+ {
+ return [
+ 'encrypter contract plaintext' => [EncrypterContract::class, 'encrypt', 'value'],
+ 'string encrypter contract plaintext' => [StringEncrypterContract::class, 'encryptString', 'value'],
+ 'constructor key' => [Encrypter::class, '__construct', 'key'],
+ 'supported key' => [Encrypter::class, 'supported', 'key'],
+ 'encrypt plaintext' => [Encrypter::class, 'encrypt', 'value'],
+ 'encrypt string plaintext' => [Encrypter::class, 'encryptString', 'value'],
+ 'hash iv' => [Encrypter::class, 'hash', 'iv'],
+ 'hash value' => [Encrypter::class, 'hash', 'value'],
+ 'hash key' => [Encrypter::class, 'hash', 'key'],
+ 'mac payload' => [Encrypter::class, 'validMacForKey', 'payload'],
+ 'mac key' => [Encrypter::class, 'validMacForKey', 'key'],
+ 'previous keys' => [Encrypter::class, 'previousKeys', 'keys'],
+ 'provider parse config' => [EncryptionServiceProvider::class, 'parseKey', 'config'],
+ 'provider key config' => [EncryptionServiceProvider::class, 'key', 'config'],
+ 'command set key' => [KeyGenerateCommand::class, 'setKeyInEnvironmentFile', 'key'],
+ 'command write key' => [KeyGenerateCommand::class, 'writeNewEnvironmentFileWith', 'key'],
+ 'atomic replacement contents' => [Filesystem::class, 'replace', 'content'],
+ 'crypt facade arguments' => [Crypt::class, '__callStatic', 'args'],
+ ];
+ }
+}
diff --git a/tests/Foundation/FoundationConfigTest.php b/tests/Foundation/FoundationConfigTest.php
index c7026a6a4..5ccbedff0 100644
--- a/tests/Foundation/FoundationConfigTest.php
+++ b/tests/Foundation/FoundationConfigTest.php
@@ -26,6 +26,13 @@ public function testAppConfigReadsStdoutLogFormatFromStdoutLogFormatEnvironmentV
$this->assertSame('json', $config['stdout_log']['format']);
}
+ public function testAppConfigTreatsNullPreviousKeysAsAnEmptyList(): void
+ {
+ $config = $this->appConfigWithEnvironment('APP_PREVIOUS_KEYS', '(null)');
+
+ $this->assertSame([], $config['previous_keys']);
+ }
+
public function testViewCompiledPathFallsBackToStoragePathWhenDirectoryDoesNotExist(): void
{
$key = 'VIEW_COMPILED_PATH';
diff --git a/tests/Foundation/Testing/Concerns/InteractsWithExceptionHandlingTest.php b/tests/Foundation/Testing/Concerns/InteractsWithExceptionHandlingTest.php
index 1a4d0a4ff..a88a474e7 100644
--- a/tests/Foundation/Testing/Concerns/InteractsWithExceptionHandlingTest.php
+++ b/tests/Foundation/Testing/Concerns/InteractsWithExceptionHandlingTest.php
@@ -6,6 +6,7 @@
use Hypervel\Contracts\Debug\ExceptionHandler;
use Hypervel\Foundation\Testing\Concerns\WithoutExceptionHandlingHandler;
+use Hypervel\Support\Facades\Exceptions;
use Hypervel\Support\Facades\Route;
use Hypervel\Support\Testing\Fakes\ExceptionHandlerFake;
use Hypervel\Testbench\TestCase;
@@ -146,4 +147,38 @@ public function testWithoutExceptionHandlingShouldReportReturnsFalse()
$this->assertFalse($handler->shouldReport(new RuntimeException));
}
+
+ public function testThrowOnReportWithoutExceptionHandling(): void
+ {
+ Exceptions::fake()->throwOnReport();
+
+ $this->withoutExceptionHandling();
+
+ Route::get('/report-exception', function (): void {
+ report(new RuntimeException('test exception'));
+ });
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('test exception');
+
+ $this->get('/report-exception');
+ }
+
+ public function testThrowOnReportRegardlessOfExceptionHandlingSetupOrder(): void
+ {
+ Exceptions::fake()->throwOnReport();
+
+ $this->withoutExceptionHandling()
+ ->withExceptionHandling()
+ ->withoutExceptionHandling();
+
+ Route::get('/report-exception-after-handler-swaps', function (): void {
+ report(new RuntimeException('test exception'));
+ });
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('test exception');
+
+ $this->get('/report-exception-after-handler-swaps');
+ }
}
diff --git a/tests/Hashing/HashFacadeTest.php b/tests/Hashing/HashFacadeTest.php
new file mode 100644
index 000000000..1e12b18b4
--- /dev/null
+++ b/tests/Hashing/HashFacadeTest.php
@@ -0,0 +1,46 @@
+shouldReceive('probe')
+ ->once()
+ ->with('value', 42)
+ ->andReturn('result');
+
+ $app = new Container;
+ $app->instance('hash', $root);
+
+ Hash::setFacadeApplication($app);
+
+ $this->assertSame('result', Hash::probe('value', 42));
+ }
+
+ public function testDynamicCallsWithoutAFacadeRootThrowTheBaseException(): void
+ {
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('A facade root has not been set.');
+
+ Hash::probe();
+ }
+}
diff --git a/tests/Hashing/HasherTest.php b/tests/Hashing/HasherTest.php
index b09e44f69..b3b0bbbc1 100644
--- a/tests/Hashing/HasherTest.php
+++ b/tests/Hashing/HasherTest.php
@@ -6,6 +6,7 @@
use Hypervel\Config\Repository as ConfigRepository;
use Hypervel\Contracts\Container\Container;
+use Hypervel\Contracts\Hashing\Hasher as HasherContract;
use Hypervel\Hashing\Argon2IdHasher;
use Hypervel\Hashing\ArgonHasher;
use Hypervel\Hashing\BcryptHasher;
@@ -13,20 +14,21 @@
use Hypervel\Tests\TestCase;
use InvalidArgumentException;
use Mockery as m;
+use PHPUnit\Framework\Attributes\Depends;
use RuntimeException;
class HasherTest extends TestCase
{
public HashManager $hashManager;
- public function setUp(): void
+ protected function setUp(): void
{
parent::setUp();
$this->hashManager = new HashManager($this->getContainer());
}
- public function testEmptyHashedValueReturnsFalse()
+ public function testEmptyHashedValueReturnsFalse(): void
{
$hasher = new BcryptHasher;
$this->assertFalse($hasher->check('password', ''));
@@ -36,7 +38,7 @@ public function testEmptyHashedValueReturnsFalse()
$this->assertFalse($hasher->check('password', ''));
}
- public function testNullHashedValueReturnsFalse()
+ public function testNullHashedValueReturnsFalse(): void
{
$hasher = new BcryptHasher;
$this->assertFalse($hasher->check('password', null));
@@ -46,7 +48,7 @@ public function testNullHashedValueReturnsFalse()
$this->assertFalse($hasher->check('password', null));
}
- public function testNullOrEmptyHashedValueDoesNotNeedRehash()
+ public function testNullOrEmptyHashedValueDoesNotNeedRehash(): void
{
$hasher = new BcryptHasher;
$this->assertFalse($hasher->needsRehash(null));
@@ -64,7 +66,7 @@ public function testNullOrEmptyHashedValueDoesNotNeedRehash()
$this->assertFalse($this->hashManager->needsRehash(''));
}
- public function testVerifiedHashersReturnFalseForNullOrEmptyHash()
+ public function testVerifiedHashersReturnFalseForNullOrEmptyHash(): void
{
$hasher = new BcryptHasher(['verify' => true]);
$this->assertFalse($hasher->check('password', null));
@@ -79,7 +81,7 @@ public function testVerifiedHashersReturnFalseForNullOrEmptyHash()
$this->assertFalse($hasher->check('password', ''));
}
- public function testBasicBcryptHashing()
+ public function testBasicBcryptHashing(): void
{
$hasher = new BcryptHasher;
$value = $hasher->make('password');
@@ -88,18 +90,46 @@ public function testBasicBcryptHashing()
$this->assertFalse($hasher->needsRehash($value));
$this->assertTrue($hasher->needsRehash($value, ['rounds' => 1]));
$this->assertSame('bcrypt', password_get_info($value)['algoName']);
+ $this->assertGreaterThanOrEqual(12, password_get_info($value)['options']['cost']);
$this->assertTrue($this->hashManager->isHashed($value));
}
- public function testBcryptValueTooLong()
+ public function testBcryptValueTooLong(): void
{
$this->expectException(InvalidArgumentException::class);
- $hasher = new BcryptHasher(['limit' => 72]);
+ $hasher = new BcryptHasher(['limit' => '72']);
$hasher->make(str_repeat('a', 73));
}
- public function testBasicArgon2iHashing()
+ public function testNumericStringConfigurationValuesAreNormalized(): void
+ {
+ $bcrypt = new BcryptHasher([
+ 'rounds' => '4',
+ 'verify' => '0',
+ ]);
+ $bcryptHash = $bcrypt->make('password');
+
+ $this->assertSame(4, password_get_info($bcryptHash)['options']['cost']);
+ $this->assertFalse($bcrypt->check('password', 'not-a-hash'));
+
+ $argon = new ArgonHasher([
+ 'memory' => '1024',
+ 'time' => '2',
+ 'threads' => '1',
+ 'verify' => '0',
+ ]);
+ $argonHash = $argon->make('password');
+
+ $this->assertSame([
+ 'memory_cost' => 1024,
+ 'time_cost' => 2,
+ 'threads' => 1,
+ ], password_get_info($argonHash)['options']);
+ $this->assertFalse($argon->check('password', 'not-a-hash'));
+ }
+
+ public function testBasicArgon2iHashing(): void
{
$hasher = new ArgonHasher;
$value = $hasher->make('password');
@@ -111,7 +141,7 @@ public function testBasicArgon2iHashing()
$this->assertTrue($this->hashManager->isHashed($value));
}
- public function testBasicArgon2idHashing()
+ public function testBasicArgon2idHashing(): void
{
$hasher = new Argon2IdHasher;
$value = $hasher->make('password');
@@ -123,10 +153,8 @@ public function testBasicArgon2idHashing()
$this->assertTrue($this->hashManager->isHashed($value));
}
- /**
- * @depends testBasicBcryptHashing
- */
- public function testBasicBcryptVerification()
+ #[Depends('testBasicBcryptHashing')]
+ public function testBasicBcryptVerification(): void
{
$this->expectException(RuntimeException::class);
@@ -135,10 +163,8 @@ public function testBasicBcryptVerification()
(new BcryptHasher(['verify' => true]))->check('password', $argonHashed);
}
- /**
- * @depends testBasicArgon2iHashing
- */
- public function testBasicArgon2iVerification()
+ #[Depends('testBasicArgon2iHashing')]
+ public function testBasicArgon2iVerification(): void
{
$this->expectException(RuntimeException::class);
@@ -147,10 +173,8 @@ public function testBasicArgon2iVerification()
(new ArgonHasher(['verify' => true]))->check('password', $bcryptHashed);
}
- /**
- * @depends testBasicArgon2idHashing
- */
- public function testBasicArgon2idVerification()
+ #[Depends('testBasicArgon2idHashing')]
+ public function testBasicArgon2idVerification(): void
{
$this->expectException(RuntimeException::class);
@@ -159,11 +183,58 @@ public function testBasicArgon2idVerification()
(new Argon2IdHasher(['verify' => true]))->check('password', $bcryptHashed);
}
- public function testIsHashedWithNonHashedValue()
+ public function testIsHashedWithNonHashedValue(): void
{
$this->assertFalse($this->hashManager->isHashed('foo'));
}
+ public function testBasicBcryptNotSupported(): void
+ {
+ $this->expectException(RuntimeException::class);
+
+ (new BcryptHasher(['rounds' => 0]))->make('password');
+ }
+
+ public function testBasicArgon2iNotSupported(): void
+ {
+ $this->expectException(RuntimeException::class);
+
+ (new ArgonHasher(['time' => 0]))->make('password');
+ }
+
+ public function testBasicArgon2idNotSupported(): void
+ {
+ $this->expectException(RuntimeException::class);
+
+ (new Argon2IdHasher(['time' => 0]))->make('password');
+ }
+
+ public function testIsHashedUsesTheConfiguredDriversInfoMethod(): void
+ {
+ $driver = m::mock(HasherContract::class);
+ $driver->shouldReceive('info')
+ ->once()
+ ->with('custom-hash')
+ ->andReturn(['algo' => 'custom']);
+
+ $manager = new HashManager($this->getContainer(['driver' => 'custom']));
+ $manager->extend('custom', fn () => $driver);
+
+ $this->assertTrue($manager->isHashed('custom-hash'));
+ }
+
+ public function testAlgorithmVerificationUsesTheProtectedExtensionMethod(): void
+ {
+ $hasher = new class(['verify' => true]) extends BcryptHasher {
+ protected function isUsingCorrectAlgorithm(string $hashedValue): bool
+ {
+ return true;
+ }
+ };
+
+ $this->assertFalse($hasher->check('password', 'not-a-hash'));
+ }
+
public function testManagerFallsBackToDefaultHashingConfig(): void
{
$container = m::mock(Container::class);
@@ -179,23 +250,25 @@ public function testManagerFallsBackToDefaultHashingConfig(): void
$this->assertInstanceOf(Argon2IdHasher::class, $manager->createArgon2idDriver());
}
- protected function getContainer()
+ protected function getContainer(?array $hashing = null): Container
{
+ $hashing ??= [
+ 'driver' => 'bcrypt',
+ 'bcrypt' => [
+ 'rounds' => 10,
+ ],
+ 'argon' => [
+ 'memory' => 65536,
+ 'threads' => 1,
+ 'time' => 4,
+ ],
+ ];
+
$container = m::mock(Container::class);
$container->shouldReceive('make')
->with('config')
- ->andReturn($config = new ConfigRepository([
- 'hashing' => [
- 'driver' => 'bcrypt',
- 'bcrypt' => [
- 'rounds' => 10,
- ],
- 'argon' => [
- 'memory' => 65536,
- 'threads' => 1,
- 'time' => 4,
- ],
- ],
+ ->andReturn(new ConfigRepository([
+ 'hashing' => $hashing,
]));
return $container;
diff --git a/tests/Hashing/SensitiveParameterTest.php b/tests/Hashing/SensitiveParameterTest.php
new file mode 100644
index 000000000..57f74fb86
--- /dev/null
+++ b/tests/Hashing/SensitiveParameterTest.php
@@ -0,0 +1,73 @@
+getParameters()[0];
+
+ $this->assertSame('value', $parameter->getName());
+ $this->assertCount(1, $parameter->getAttributes(SensitiveParameter::class));
+ }
+
+ #[DataProvider('sensitiveParameters')]
+ public function testPlaintextBearingParametersAreRedacted(
+ string $class,
+ string $method,
+ string $parameterName
+ ): void {
+ $parameters = (new ReflectionMethod($class, $method))->getParameters();
+ $parameter = null;
+
+ foreach ($parameters as $candidate) {
+ if ($candidate->getName() === $parameterName) {
+ $parameter = $candidate;
+
+ break;
+ }
+ }
+
+ $this->assertNotNull($parameter);
+ $this->assertCount(1, $parameter->getAttributes(SensitiveParameter::class));
+ }
+
+ /**
+ * Provide every named method parameter that can carry plaintext.
+ *
+ * @return array
+ */
+ public static function sensitiveParameters(): array
+ {
+ return [
+ 'hasher contract make plaintext' => [HasherContract::class, 'make', 'value'],
+ 'hasher contract check plaintext' => [HasherContract::class, 'check', 'value'],
+ 'abstract hasher check plaintext' => [AbstractHasher::class, 'check', 'value'],
+ 'argon make plaintext' => [ArgonHasher::class, 'make', 'value'],
+ 'argon check plaintext' => [ArgonHasher::class, 'check', 'value'],
+ 'argon2id check plaintext' => [Argon2IdHasher::class, 'check', 'value'],
+ 'bcrypt make plaintext' => [BcryptHasher::class, 'make', 'value'],
+ 'bcrypt check plaintext' => [BcryptHasher::class, 'check', 'value'],
+ 'manager make plaintext' => [HashManager::class, 'make', 'value'],
+ 'manager check plaintext' => [HashManager::class, 'check', 'value'],
+ 'manager hashed value' => [HashManager::class, 'isHashed', 'value'],
+ 'hash facade arguments' => [Hash::class, '__callStatic', 'args'],
+ ];
+ }
+}
diff --git a/tests/Integration/Encryption/EncryptionTest.php b/tests/Integration/Encryption/EncryptionTest.php
index 12a5c374b..086bdd271 100644
--- a/tests/Integration/Encryption/EncryptionTest.php
+++ b/tests/Integration/Encryption/EncryptionTest.php
@@ -5,8 +5,12 @@
namespace Hypervel\Tests\Integration\Encryption;
use Hypervel\Encryption\Encrypter;
+use Hypervel\Encryption\EncryptionServiceProvider;
use Hypervel\Testbench\Attributes\WithConfig;
use Hypervel\Testbench\TestCase;
+use Laravel\SerializableClosure\SerializableClosure;
+use Laravel\SerializableClosure\Serializers\Native;
+use Laravel\SerializableClosure\Serializers\Signed;
use RuntimeException;
#[WithConfig('app.key', 'base64:IUHRqAQ99pZ0A1MPjbuv1D6ff3jxv0GIvS2qIW4JNU4=')]
@@ -25,4 +29,25 @@ public function testEncryptionWillNotBeInstantiableWhenMissingAppKey()
$this->app->make('encrypter');
}
+
+ public function testEncryptionProviderConfiguresSerializableClosureSigner(): void
+ {
+ (new EncryptionServiceProvider($this->app))->register();
+
+ $serializable = new SerializableClosure(static fn (): string => 'value');
+
+ $this->assertInstanceOf(Signed::class, $serializable->__serialize()['serializable']);
+ }
+
+ public function testEncryptionProviderClearsStaleSerializableClosureSignerWhenKeyIsMissing(): void
+ {
+ SerializableClosure::setSecretKey('stale-key');
+ $this->app['config']->set('app.key', null);
+
+ (new EncryptionServiceProvider($this->app))->register();
+
+ $serializable = new SerializableClosure(static fn (): string => 'value');
+
+ $this->assertInstanceOf(Native::class, $serializable->__serialize()['serializable']);
+ }
}
diff --git a/tests/Integration/Encryption/KeyGenerateCommandTest.php b/tests/Integration/Encryption/KeyGenerateCommandTest.php
index 88da57717..cf3694208 100644
--- a/tests/Integration/Encryption/KeyGenerateCommandTest.php
+++ b/tests/Integration/Encryption/KeyGenerateCommandTest.php
@@ -4,32 +4,39 @@
namespace Hypervel\Tests\Integration\Encryption;
+use Hypervel\Contracts\Filesystem\FileNotFoundException;
use Hypervel\Contracts\Foundation\Application as ApplicationContract;
+use Hypervel\Encryption\Commands\KeyGenerateCommand;
+use Hypervel\Filesystem\Filesystem;
use Hypervel\Testbench\TestCase;
+use Hypervel\Testing\ParallelTesting;
+use Override;
+use PHPUnit\Framework\Attributes\DataProvider;
+use RuntimeException;
+use Throwable;
class KeyGenerateCommandTest extends TestCase
{
private string $envDir;
+ private Filesystem $filesystem;
+
+ #[Override]
protected function setUp(): void
{
parent::setUp();
- $this->envDir = sys_get_temp_dir() . '/hypervel-key-test-' . uniqid();
- mkdir($this->envDir);
+ $this->filesystem = new Filesystem;
+ $this->envDir = ParallelTesting::tempDir('KeyGenerateCommandTest');
+ $this->filesystem->deleteDirectory($this->envDir);
+ $this->filesystem->ensureDirectoryExists($this->envDir);
+ $this->app->useEnvironmentPath($this->envDir);
}
+ #[Override]
protected function tearDown(): void
{
- $envFile = $this->envDir . '/.env';
-
- if (file_exists($envFile)) {
- unlink($envFile);
- }
-
- if (is_dir($this->envDir)) {
- rmdir($this->envDir);
- }
+ $this->filesystem->deleteDirectory($this->envDir);
parent::tearDown();
}
@@ -136,4 +143,173 @@ public function testGeneratedKeyHasCorrectLengthForCipher()
$this->assertNotEmpty($matches[1]);
$this->assertSame(32, strlen(base64_decode($matches[1])));
}
+
+ public function testProhibitedCommandDoesNotGenerateOrPublishAKey(): void
+ {
+ $this->app['config']->set('app.key', '');
+ $path = $this->envDir . '/.env';
+ file_put_contents($path, 'APP_KEY=');
+ KeyGenerateCommand::prohibit();
+
+ $this->artisan('key:generate')
+ ->expectsOutputToContain('This command is prohibited from running in this environment.')
+ ->assertSuccessful();
+
+ $this->assertSame('APP_KEY=', file_get_contents($path));
+ $this->assertSame('', $this->app['config']->get('app.key'));
+ }
+
+ #[DataProvider('quotedKeyLines')]
+ public function testExactQuotedKeyLinesAreReplaced(string $configuredKey, string $line, string $suffix): void
+ {
+ $this->app['config']->set('app.key', $configuredKey);
+ $path = $this->envDir . '/.env';
+ file_put_contents($path, $line);
+
+ $this->artisan('key:generate', ['--force' => true])
+ ->expectsOutputToContain('Application key set successfully.')
+ ->assertSuccessful();
+
+ $contents = file_get_contents($path);
+ $generatedKey = $this->app['config']->get('app.key');
+
+ $this->assertIsString($generatedKey);
+ $this->assertStringStartsWith('base64:', $generatedKey);
+ $this->assertSame("APP_KEY={$generatedKey}{$suffix}", $contents);
+ }
+
+ /**
+ * Provide supported quoted environment key lines.
+ *
+ * @return array
+ */
+ public static function quotedKeyLines(): array
+ {
+ return [
+ 'empty double quotes' => ['', 'APP_KEY=""', ''],
+ 'empty single quotes' => ['', "APP_KEY=''", ''],
+ 'quoted current key' => ['base64:current', 'APP_KEY="base64:current"', ''],
+ 'CRLF quoted current key' => ['base64:current', "APP_KEY=\"base64:current\"\r\nAPP_NAME=Hypervel", "\r\nAPP_NAME=Hypervel"],
+ ];
+ }
+
+ #[DataProvider('nonMatchingKeyLines')]
+ public function testNonMatchingKeyLinesAreNotReplaced(string $line): void
+ {
+ $this->app['config']->set('app.key', 'base64:current');
+ $path = $this->envDir . '/.env';
+ file_put_contents($path, $line);
+
+ $this->artisan('key:generate', ['--force' => true])
+ ->assertSuccessful();
+
+ $this->assertSame($line, file_get_contents($path));
+ $this->assertSame('base64:current', $this->app['config']->get('app.key'));
+ }
+
+ /**
+ * Provide environment key lines that do not exactly match the configured key.
+ *
+ * @return array
+ */
+ public static function nonMatchingKeyLines(): array
+ {
+ return [
+ 'longer prefixed key' => ['APP_KEY=base64:currentsuffix'],
+ 'mismatched quotes' => ['APP_KEY="base64:current\''],
+ ];
+ }
+
+ public function testMissingEnvironmentFileThrowsTheFilesystemException(): void
+ {
+ $this->app['config']->set('app.key', '');
+
+ $this->expectException(FileNotFoundException::class);
+ $this->expectExceptionMessage('File does not exist at path');
+
+ $this->artisan('key:generate')->run();
+ }
+
+ public function testEnvironmentReadFailureRemainsAFileNotFoundException(): void
+ {
+ $this->app['config']->set('app.key', '');
+ $path = $this->envDir . '/.env';
+ file_put_contents($path, 'APP_KEY=');
+ $filesystem = new FaultingKeyEnvironmentFilesystem;
+ $filesystem->getFailure = new FileNotFoundException("Unable to read file at path {$path}.");
+ $this->app->instance(Filesystem::class, $filesystem);
+
+ $this->expectException(FileNotFoundException::class);
+ $this->expectExceptionMessage("Unable to read file at path {$path}.");
+
+ $this->artisan('key:generate')->run();
+ }
+
+ public function testEnvironmentReplacementFailureDoesNotPublishPartialState(): void
+ {
+ $this->app['config']->set('app.key', '');
+ $path = $this->envDir . '/.env';
+ file_put_contents($path, 'APP_KEY=');
+ $filesystem = new FaultingKeyEnvironmentFilesystem;
+ $filesystem->replaceFailure = new RuntimeException('Unable to write the complete replacement contents.');
+ $this->app->instance(Filesystem::class, $filesystem);
+
+ try {
+ $this->artisan('key:generate')->run();
+
+ $this->fail('Expected the replacement failure to be thrown.');
+ } catch (RuntimeException $exception) {
+ $this->assertSame('Unable to write the complete replacement contents.', $exception->getMessage());
+ }
+
+ $this->assertSame('APP_KEY=', file_get_contents($path));
+ $this->assertSame('', $this->app['config']->get('app.key'));
+ }
+
+ public function testEnvironmentFileModeIsPreservedWhenTheKeyIsReplaced(): void
+ {
+ $this->app['config']->set('app.key', '');
+ $path = $this->envDir . '/.env';
+ file_put_contents($path, 'APP_KEY=');
+ chmod($path, 0640);
+
+ $this->artisan('key:generate')
+ ->assertSuccessful();
+
+ clearstatcache(true, $path);
+ $this->assertSame(0640, fileperms($path) & 0777);
+ }
+}
+
+class FaultingKeyEnvironmentFilesystem extends Filesystem
+{
+ public ?Throwable $getFailure = null;
+
+ public ?Throwable $replaceFailure = null;
+
+ /**
+ * Get the contents of a file.
+ */
+ #[Override]
+ public function get(string $path, bool $lock = false): string
+ {
+ if ($this->getFailure !== null) {
+ throw $this->getFailure;
+ }
+
+ return parent::get($path, $lock);
+ }
+
+ /**
+ * Write the contents of a file, replacing it atomically if it already exists.
+ */
+ #[Override]
+ public function replace(string $path, string $content, ?int $mode = null): void
+ {
+ if ($this->replaceFailure !== null) {
+ throw $this->replaceFailure;
+ }
+
+ parent::replace($path, $content, $mode);
+ }
}
diff --git a/tests/Integration/Queue/QueueFakeTest.php b/tests/Integration/Queue/QueueFakeTest.php
index 3b3feb333..236be183a 100644
--- a/tests/Integration/Queue/QueueFakeTest.php
+++ b/tests/Integration/Queue/QueueFakeTest.php
@@ -9,6 +9,7 @@
use Hypervel\Support\Facades\Queue;
use Hypervel\Support\Testing\Fakes\QueueFake;
use Hypervel\Testbench\TestCase;
+use PHPUnit\Framework\ExpectationFailedException;
class QueueFakeTest extends TestCase
{
@@ -60,6 +61,26 @@ public function testFakeExceptForReturnValue()
$this->assertEquals('test-value', $result);
}
+
+ public function testAssertPushedOnce(): void
+ {
+ Queue::fake();
+ Queue::push(new TestJob);
+
+ Queue::assertPushedOnce(TestJob::class);
+
+ Queue::push(new TestJob);
+
+ try {
+ Queue::assertPushedOnce(TestJob::class);
+ $this->fail();
+ } catch (ExpectationFailedException $exception) {
+ $this->assertStringContainsString(
+ 'The expected [' . TestJob::class . '] job was pushed 2 times instead of 1 time.',
+ $exception->getMessage()
+ );
+ }
+ }
}
class TestJob
diff --git a/tests/Sanctum/SanctumConfigTest.php b/tests/Sanctum/SanctumConfigTest.php
new file mode 100644
index 000000000..3a5648bd2
--- /dev/null
+++ b/tests/Sanctum/SanctumConfigTest.php
@@ -0,0 +1,47 @@
+setEnvironmentValue($key, '(null)');
+
+ $config = require dirname(__DIR__, 2) . '/src/sanctum/config/sanctum.php';
+
+ $this->assertSame([''], $config['stateful_domains']);
+ } finally {
+ $originalPutenv === false
+ ? putenv($key)
+ : putenv("{$key}={$originalPutenv}");
+
+ if ($originalServerExists) {
+ $_SERVER[$key] = $originalServer;
+ } else {
+ unset($_SERVER[$key]);
+ }
+
+ if ($originalEnvExists) {
+ $_ENV[$key] = $originalEnv;
+ } else {
+ unset($_ENV[$key]);
+ }
+
+ Env::flushRepository();
+ }
+ }
+}
diff --git a/tests/Support/Base62Test.php b/tests/Support/Base62Test.php
index 28f05b513..0ab60fef4 100644
--- a/tests/Support/Base62Test.php
+++ b/tests/Support/Base62Test.php
@@ -10,24 +10,36 @@
class Base62Test extends TestCase
{
- public function testEncode()
+ public function testEncode(): void
{
$this->assertSame('fMYsmVDc', Base62::encode(145667762035560));
}
- public function testDecode()
+ public function testEncodeZero(): void
+ {
+ $this->assertSame('0', Base62::encode(0));
+ }
+
+ public function testEncodeNegativeNumberThrowsException(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+
+ Base62::encode(-1);
+ }
+
+ public function testDecode(): void
{
$this->assertSame(145667762035560, Base62::decode('fMYsmVDc'));
}
- public function testDecodeWithInvalidCharactersThrowsException()
+ public function testDecodeWithInvalidCharactersThrowsException(): void
{
$this->expectException(InvalidArgumentException::class);
Base62::decode('fMYsmVDc***');
}
- public function testDecodeEmptyStringThrowsException()
+ public function testDecodeEmptyStringThrowsException(): void
{
$this->expectException(InvalidArgumentException::class);
diff --git a/tests/Support/ComposerFileTest.php b/tests/Support/ComposerFileTest.php
new file mode 100644
index 000000000..86d9da963
--- /dev/null
+++ b/tests/Support/ComposerFileTest.php
@@ -0,0 +1,119 @@
+tempDirectory = ParallelTesting::tempDir('SupportComposerFileTest');
+ mkdir($this->tempDirectory, 0777, true);
+
+ $this->composerFile = $this->tempDirectory . '/composer.json';
+ }
+
+ protected function tearDown(): void
+ {
+ (new Filesystem)->deleteDirectory($this->tempDirectory);
+
+ parent::tearDown();
+ }
+
+ public function testHasPackageReadsBothDependencySections(): void
+ {
+ file_put_contents($this->composerFile, json_encode([
+ 'require' => ['vendor/runtime' => '^1.0'],
+ 'require-dev' => ['vendor/testing' => '^1.0'],
+ ], JSON_THROW_ON_ERROR));
+
+ $composer = new Composer(new Filesystem, $this->tempDirectory);
+
+ $this->assertTrue($composer->hasPackage('vendor/runtime'));
+ $this->assertTrue($composer->hasPackage('vendor/testing'));
+ $this->assertFalse($composer->hasPackage('vendor/missing'));
+ }
+
+ public function testHasPackageRejectsMalformedJson(): void
+ {
+ file_put_contents($this->composerFile, '{');
+
+ $this->expectException(JsonException::class);
+
+ (new Composer(new Filesystem, $this->tempDirectory))->hasPackage('vendor/package');
+ }
+
+ public function testModifyReplacesComposerFileAndPreservesItsMode(): void
+ {
+ file_put_contents($this->composerFile, '{"name":"hypervel/app"}');
+ chmod($this->composerFile, 0640);
+
+ (new Composer(new Filesystem, $this->tempDirectory))->modify(function (array $composer): array {
+ $composer['require']['vendor/package'] = '^1.0';
+
+ return $composer;
+ });
+
+ $this->assertSame([
+ 'name' => 'hypervel/app',
+ 'require' => ['vendor/package' => '^1.0'],
+ ], json_decode(file_get_contents($this->composerFile), true, 512, JSON_THROW_ON_ERROR));
+ $this->assertSame(0640, fileperms($this->composerFile) & 0777);
+ }
+
+ public function testModifyRejectsUnencodableCallbackOutput(): void
+ {
+ file_put_contents($this->composerFile, '{}');
+ $stream = fopen('php://memory', 'r');
+
+ try {
+ $this->expectException(JsonException::class);
+
+ (new Composer(new Filesystem, $this->tempDirectory))->modify(
+ fn (): array => ['stream' => $stream],
+ );
+ } finally {
+ fclose($stream);
+ }
+ }
+
+ public function testModifyPreservesTheOriginalFileWhenReplacementFails(): void
+ {
+ file_put_contents($this->composerFile, $original = '{"name":"hypervel/app"}');
+
+ $filesystem = new FailingComposerFilesystem;
+
+ try {
+ (new Composer($filesystem, $this->tempDirectory))->modify(
+ fn (array $composer): array => [...$composer, 'description' => 'changed'],
+ );
+
+ $this->fail('Expected the replacement to fail.');
+ } catch (RuntimeException $exception) {
+ $this->assertSame('Unable to replace Composer file.', $exception->getMessage());
+ $this->assertSame($original, file_get_contents($this->composerFile));
+ }
+ }
+}
+
+class FailingComposerFilesystem extends Filesystem
+{
+ public function replace(string $path, string $content, ?int $mode = null): void
+ {
+ throw new RuntimeException('Unable to replace Composer file.');
+ }
+}
diff --git a/tests/Support/DataObjectTest.php b/tests/Support/DataObjectTest.php
index 424bdb725..5e323bad4 100644
--- a/tests/Support/DataObjectTest.php
+++ b/tests/Support/DataObjectTest.php
@@ -379,6 +379,93 @@ public function testMakeWithAutoResolveNullValues(): void
$this->assertSame(TestGenderEnum::Male, $user->gender);
}
+ public function testEmptyDataObjectCanBeCreatedAndSerialized(): void
+ {
+ $object = EmptyDataObject::make([], true);
+
+ $this->assertSame([], $object->toArray());
+ }
+
+ public function testAutoResolveSkipsUntypedAndIntersectionProperties(): void
+ {
+ $untyped = UntypedDataObject::make([], true);
+ $intersectionValue = new IntersectionValue;
+ $intersection = IntersectionDataObject::make(['value' => $intersectionValue], true);
+
+ $this->assertSame('default', $untyped->value);
+ $this->assertSame($intersectionValue, $intersection->value);
+ }
+
+ public function testAutoResolveSkipsUnsupportedScalarUnions(): void
+ {
+ $object = ScalarUnionDataObject::make(['value' => 'value'], true);
+
+ $this->assertSame('value', $object->value);
+ }
+
+ public function testAutoResolveSkipsIntersectionMembersInDnfUnions(): void
+ {
+ $address = new TestAddressDataObject('123 Main St', 'New York', '10001');
+ $object = DnfUnionDataObject::make(['value' => $address], true);
+
+ $this->assertSame($address, $object->value);
+ }
+
+ public function testAutoResolvePreservesMissingDefaultsAndExplicitNulls(): void
+ {
+ $defaulted = DefaultedDependencyDataObject::make([], true);
+ $explicitNull = DefaultedDependencyDataObject::make(['gender' => null], true);
+
+ $this->assertSame(TestGenderEnum::Female, $defaulted->gender);
+ $this->assertNull($explicitNull->gender);
+ }
+
+ public function testAutoResolvePreservesExistingNestedDataObject(): void
+ {
+ $address = new TestAddressDataObject('123 Main St', 'New York', '10001');
+
+ $user = TestUserDataObject::make([
+ 'name' => 'John Doe',
+ 'gender' => TestGenderEnum::Male,
+ 'address' => $address,
+ 'created_at' => null,
+ ], true);
+
+ $this->assertSame($address, $user->address);
+ }
+
+ public function testNestedDependenciesUseEachOwningClassKeyConvention(): void
+ {
+ $object = OwnerKeyDataObject::make([
+ 'owner_child' => [
+ 'child_value' => 'nested',
+ ],
+ ], true);
+
+ $this->assertSame('nested', $object->child->value);
+ }
+
+ public function testNestedDependenciesUseTheNestedClassResolver(): void
+ {
+ $object = RootResolverDataObject::make([
+ 'child' => [
+ 'value' => 'resolved',
+ ],
+ ], true);
+
+ $this->assertSame('child:resolved', $object->child->value->value);
+ }
+
+ public function testEmptyDependencyMapsAreCached(): void
+ {
+ DependencylessDataObject::$dependencyLookups = 0;
+
+ DependencylessDataObject::make([], true);
+ DependencylessDataObject::make([], true);
+
+ $this->assertSame(1, DependencylessDataObject::$dependencyLookups);
+ }
+
public function testFlushStateRestoresStaticDefaults(): void
{
TestDataObject::make($this->getData());
@@ -513,3 +600,127 @@ enum TestGenderEnum: string
case Male = 'male';
case Female = 'female';
}
+
+class EmptyDataObject extends DataObject
+{
+}
+
+class UntypedDataObject extends DataObject
+{
+ public $value = 'default';
+}
+
+interface FirstIntersectionType
+{
+}
+
+interface SecondIntersectionType
+{
+}
+
+class IntersectionValue implements FirstIntersectionType, SecondIntersectionType
+{
+}
+
+class IntersectionDataObject extends DataObject
+{
+ public function __construct(public FirstIntersectionType&SecondIntersectionType $value)
+ {
+ }
+}
+
+class DnfUnionDataObject extends DataObject
+{
+ public function __construct(public (FirstIntersectionType&SecondIntersectionType)|TestAddressDataObject $value)
+ {
+ }
+}
+
+class ScalarUnionDataObject extends DataObject
+{
+ public function __construct(public int|string $value)
+ {
+ }
+}
+
+class DefaultedDependencyDataObject extends DataObject
+{
+ public function __construct(public ?TestGenderEnum $gender = TestGenderEnum::Female)
+ {
+ }
+}
+
+class OwnerKeyDataObject extends DataObject
+{
+ public function __construct(public ChildKeyDataObject $child)
+ {
+ }
+
+ public static function convertPropertyToDataKey(string $input): string
+ {
+ return 'owner_' . $input;
+ }
+}
+
+class ChildKeyDataObject extends DataObject
+{
+ public function __construct(public string $value)
+ {
+ }
+
+ public static function convertPropertyToDataKey(string $input): string
+ {
+ return 'child_' . $input;
+ }
+}
+
+class RootResolverDataObject extends DataObject
+{
+ public function __construct(public ChildResolverDataObject $child)
+ {
+ }
+
+ protected static function getCustomizedDependencies(): array
+ {
+ return [
+ ResolverValue::class => fn (string $value) => new ResolverValue('root:' . $value),
+ ];
+ }
+}
+
+class ChildResolverDataObject extends DataObject
+{
+ public function __construct(public ResolverValue $value)
+ {
+ }
+
+ protected static function getCustomizedDependencies(): array
+ {
+ return [
+ ResolverValue::class => fn (string $value) => new ResolverValue('child:' . $value),
+ ];
+ }
+}
+
+class ResolverValue
+{
+ public function __construct(public string $value)
+ {
+ }
+}
+
+class DependencylessDataObject extends DataObject
+{
+ public static int $dependencyLookups = 0;
+
+ public function __construct(public string $value = 'default')
+ {
+ }
+
+ protected static function getCustomizedDependencies(): array
+ {
+ ++static::$dependencyLookups;
+
+ return parent::getCustomizedDependencies();
+ }
+}
diff --git a/tests/Support/EnvFileTest.php b/tests/Support/EnvFileTest.php
new file mode 100644
index 000000000..7f8ce7057
--- /dev/null
+++ b/tests/Support/EnvFileTest.php
@@ -0,0 +1,51 @@
+tempDirectory = ParallelTesting::tempDir('SupportEnvFileTest');
+ mkdir($this->tempDirectory, 0777, true);
+
+ $this->envPath = $this->tempDirectory . '/.env';
+ file_put_contents($this->envPath, 'APP_NAME=old');
+ chmod($this->envPath, 0640);
+ }
+
+ protected function tearDown(): void
+ {
+ (new Filesystem)->deleteDirectory($this->tempDirectory);
+
+ parent::tearDown();
+ }
+
+ public function testWriteVariableReplacesTheFileAndPreservesItsMode(): void
+ {
+ Env::writeVariable('APP_NAME', 'new', $this->envPath, overwrite: true);
+
+ $this->assertSame('APP_NAME=new', file_get_contents($this->envPath));
+ $this->assertSame(0640, fileperms($this->envPath) & 0777);
+ }
+
+ public function testWriteVariablesQuotesPunctuationOutsideTheAlphanumericRange(): void
+ {
+ Env::writeVariables(['APP_NAME' => 'name_with_underscore'], $this->envPath, overwrite: true);
+
+ $this->assertSame('APP_NAME="name_with_underscore"', file_get_contents($this->envPath));
+ }
+}
diff --git a/tests/Support/FileinfoMimeTypeGuesserNonCoroutineTest.php b/tests/Support/FileinfoMimeTypeGuesserNonCoroutineTest.php
new file mode 100644
index 000000000..55676a403
--- /dev/null
+++ b/tests/Support/FileinfoMimeTypeGuesserNonCoroutineTest.php
@@ -0,0 +1,61 @@
+assertTrue(stream_wrapper_register($scheme, DisappearingFileStreamWrapper::class));
+
+ // PHPUnit replaces Hypervel's production error handler, so reproduce its warning-to-exception conversion here.
+ set_error_handler(
+ static fn (int $severity, string $message, string $file, int $line): never => throw new ErrorException(
+ $message,
+ 0,
+ $severity,
+ $file,
+ $line,
+ )
+ );
+
+ try {
+ $this->assertNull((new FileinfoMimeTypeGuesser)->guessMimeType($scheme . '://file'));
+ } finally {
+ restore_error_handler();
+ stream_wrapper_unregister($scheme);
+ }
+ }
+}
+
+class DisappearingFileStreamWrapper
+{
+ public mixed $context;
+
+ public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool
+ {
+ return false;
+ }
+
+ public function url_stat(string $path, int $flags): array
+ {
+ return [
+ 2 => 0100444,
+ 7 => 1,
+ 'mode' => 0100444,
+ 'size' => 1,
+ ];
+ }
+}
diff --git a/tests/Support/FileinfoMimeTypeGuesserTest.php b/tests/Support/FileinfoMimeTypeGuesserTest.php
index 2705661e0..ffc8cc74b 100644
--- a/tests/Support/FileinfoMimeTypeGuesserTest.php
+++ b/tests/Support/FileinfoMimeTypeGuesserTest.php
@@ -16,7 +16,7 @@
#[RequiresPhpExtension('fileinfo')]
class FileinfoMimeTypeGuesserTest extends TestCase
{
- public function testGuessMimeTypeWithInvalidFile()
+ public function testGuessMimeTypeWithInvalidFile(): void
{
$this->expectException(InvalidArgumentException::class);
@@ -24,7 +24,7 @@ public function testGuessMimeTypeWithInvalidFile()
->guessMimeType(__DIR__ . '/unknown');
}
- public function testGuessMimeType()
+ public function testGuessMimeType(): void
{
$mimeType = (new FileinfoMimeTypeGuesser)
->guessMimeType(__DIR__ . '/Fixtures/test.gif');
@@ -32,7 +32,7 @@ public function testGuessMimeType()
$this->assertEquals('image/gif', $mimeType);
}
- public function testGuessMimeTypeIsCoroutineScoped()
+ public function testGuessMimeTypeIsCoroutineScoped(): void
{
$guesser = new FileinfoMimeTypeGuesser;
$key = FileinfoMimeTypeGuesser::FINFO_CONTEXT_KEY_PREFIX;
diff --git a/tests/Support/LotteryTest.php b/tests/Support/LotteryTest.php
index db6cd11f7..6a0b8ed55 100644
--- a/tests/Support/LotteryTest.php
+++ b/tests/Support/LotteryTest.php
@@ -104,6 +104,30 @@ public function testItCanForceLosingResultInTests()
], $result);
}
+ public function testAlwaysWinRestoresNormalResultsWhenTheCallbackThrows(): void
+ {
+ try {
+ Lottery::alwaysWin(fn () => throw new RuntimeException('Callback failed.'));
+ $this->fail('Expected the callback to fail.');
+ } catch (RuntimeException $exception) {
+ $this->assertSame('Callback failed.', $exception->getMessage());
+ }
+
+ $this->assertFalse(Lottery::odds(0, 1)->choose());
+ }
+
+ public function testAlwaysLoseRestoresNormalResultsWhenTheCallbackThrows(): void
+ {
+ try {
+ Lottery::alwaysLose(fn () => throw new RuntimeException('Callback failed.'));
+ $this->fail('Expected the callback to fail.');
+ } catch (RuntimeException $exception) {
+ $this->assertSame('Callback failed.', $exception->getMessage());
+ }
+
+ $this->assertTrue(Lottery::odds(1, 1)->choose());
+ }
+
public function testItCanForceTheResultViaSequence()
{
$result = null;
@@ -141,6 +165,22 @@ public function testItCanHandleMissingSequenceItems()
Lottery::odds(1, 10000)->winner(fn () => 'winner')->loser(fn () => 'loser')->choose();
}
+ public function testMissingSequenceFallbackRestoresTheSequenceWhenNormalResolutionThrows(): void
+ {
+ ThrowingLottery::failNextNormalResult();
+ ThrowingLottery::forceResultWithSequence([1 => false]);
+
+ try {
+ ThrowingLottery::odds(1, 1)->choose();
+ $this->fail('Expected normal result generation to fail.');
+ } catch (RuntimeException $exception) {
+ $this->assertSame('Result generation failed.', $exception->getMessage());
+ }
+
+ $this->assertTrue(ThrowingLottery::odds(1, 1)->choose());
+ $this->assertFalse(ThrowingLottery::odds(1, 1)->choose());
+ }
+
public function testFlushStateRestoresNormalResultFactory()
{
Lottery::alwaysLose();
@@ -194,3 +234,24 @@ public function testItCanLoseWithFloat()
$this->assertTrue($loses);
}
}
+
+class ThrowingLottery extends Lottery
+{
+ protected static bool $failNextNormalResult = false;
+
+ public static function failNextNormalResult(): void
+ {
+ static::$failNextNormalResult = true;
+ }
+
+ protected static function resultFactory(): callable
+ {
+ if (static::$resultFactory === null && static::$failNextNormalResult) {
+ static::$failNextNormalResult = false;
+
+ return fn () => throw new RuntimeException('Result generation failed.');
+ }
+
+ return parent::resultFactory();
+ }
+}
diff --git a/tests/Support/NumberTest.php b/tests/Support/NumberTest.php
index b44922582..c2c0d0bd5 100644
--- a/tests/Support/NumberTest.php
+++ b/tests/Support/NumberTest.php
@@ -7,6 +7,7 @@
use Hypervel\Context\CoroutineContext;
use Hypervel\Support\Number;
use Hypervel\Tests\TestCase;
+use InvalidArgumentException;
use PHPUnit\Framework\Attributes\RequiresPhpExtension;
use function Hypervel\Coroutine\parallel;
@@ -106,6 +107,10 @@ public function testFileSize(): void
$this->assertSame('1 KB', Number::fileSize(1024));
$this->assertSame('1 MB', Number::fileSize(1024 * 1024));
$this->assertSame('1 GB', Number::fileSize(1024 * 1024 * 1024));
+ $this->assertSame('-2 KB', Number::fileSize(-2048));
+ $this->assertSame('∞ B', Number::fileSize(INF));
+ $this->assertSame('-∞ B', Number::fileSize(-INF));
+ $this->assertSame('NaN B', Number::fileSize(NAN));
}
public function testFileSizeWithPrecision(): void
@@ -129,6 +134,10 @@ public function testForHumans(): void
$this->assertSame('1 thousand', Number::forHumans(1000));
$this->assertSame('1 million', Number::forHumans(1000000));
$this->assertSame('1 billion', Number::forHumans(1000000000));
+ $this->assertSame('1 million', Number::forHumans(999500));
+ $this->assertSame('∞', Number::forHumans(INF));
+ $this->assertSame('-∞', Number::forHumans(-INF));
+ $this->assertSame('NaN', Number::forHumans(NAN));
}
public function testClamp(): void
@@ -143,6 +152,15 @@ public function testPairs(): void
{
$this->assertSame([[0, 9], [10, 19], [20, 25]], Number::pairs(25, 10));
$this->assertSame([[0, 10], [10, 20], [20, 25]], Number::pairs(25, 10, 0, 0));
+ $this->assertSame(Number::pairs(25, 10), Number::pairs(25, -10));
+ }
+
+ public function testPairsRejectsZeroStep(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('The $by argument must not be zero.');
+
+ Number::pairs(25, 0);
}
public function testTrim(): void
@@ -150,6 +168,9 @@ public function testTrim(): void
$this->assertSame(1, Number::trim(1.0));
$this->assertSame(1.5, Number::trim(1.50));
$this->assertSame(1.23, Number::trim(1.230));
+ $this->assertSame(INF, Number::trim(INF));
+ $this->assertSame(-INF, Number::trim(-INF));
+ $this->assertNan(Number::trim(NAN));
}
// ==========================================================================
diff --git a/tests/Support/PendingBatchFakeTest.php b/tests/Support/PendingBatchFakeTest.php
new file mode 100644
index 000000000..1dd2b067e
--- /dev/null
+++ b/tests/Support/PendingBatchFakeTest.php
@@ -0,0 +1,53 @@
+batch([
+ new PendingBatchFakeJob('first'),
+ new PendingBatchFakeJob('second'),
+ new PendingBatchFakeJob('third'),
+ ]);
+
+ $this->assertTrue($batch->hasJobs([
+ new PendingBatchFakeJob('first'),
+ PendingBatchFakeJob::class,
+ fn (PendingBatchFakeJob $job): bool => $job->value === 'third',
+ ]));
+ $this->assertFalse($batch->hasJobs([
+ new PendingBatchFakeJob('first'),
+ new PendingBatchFakeJob('wrong'),
+ new PendingBatchFakeJob('third'),
+ ]));
+ $this->assertFalse($batch->hasJobs([
+ new PendingBatchFakeJob('first'),
+ new PendingBatchFakeJob('second'),
+ ]));
+ }
+
+ private function batch(array $jobs): PendingBatchFake
+ {
+ $bus = new BusFake(m::mock(QueueingDispatcher::class));
+
+ return new PendingBatchFake($bus, new Collection($jobs));
+ }
+}
+
+class PendingBatchFakeJob
+{
+ public function __construct(public string $value)
+ {
+ }
+}
diff --git a/tests/Support/SupportEncodedHtmlStringTest.php b/tests/Support/SupportEncodedHtmlStringTest.php
index 954744cc3..ee9dfb204 100644
--- a/tests/Support/SupportEncodedHtmlStringTest.php
+++ b/tests/Support/SupportEncodedHtmlStringTest.php
@@ -75,4 +75,31 @@ function () {
$this->assertSame(['b:before', 'b:after'], $results['b']);
$this->assertSame('boot:final', (new EncodedHtmlString('final'))->toHtml());
}
+
+ public function testBootEncoderAcceptsEveryCallableShapeAndCanBeReset(): void
+ {
+ EncodedHtmlString::encodeUsing(new class {
+ public function __invoke(string $value): string
+ {
+ return "invokable:{$value}";
+ }
+ });
+
+ $this->assertSame('invokable:value', (new EncodedHtmlString('value'))->toHtml());
+
+ $encoder = new class {
+ public function encode(string $value): string
+ {
+ return "array:{$value}";
+ }
+ };
+
+ EncodedHtmlString::encodeUsing([$encoder, 'encode']);
+
+ $this->assertSame('array:value', (new EncodedHtmlString('value'))->toHtml());
+
+ EncodedHtmlString::encodeUsing();
+
+ $this->assertSame('<value>', (new EncodedHtmlString(''))->toHtml());
+ }
}
diff --git a/tests/Support/SupportJsTest.php b/tests/Support/SupportJsTest.php
index 200339da3..9cd12a1ef 100644
--- a/tests/Support/SupportJsTest.php
+++ b/tests/Support/SupportJsTest.php
@@ -32,6 +32,21 @@ public function testScalars()
);
}
+ public function testInvalidUtf8IsSubstituted(): void
+ {
+ $this->assertSame('"�1"', Js::encode("\xB1\x31"));
+ $this->assertSame("'�1'", (string) Js::from("\xB1\x31"));
+
+ $jsonable = new class implements Jsonable {
+ public function toJson(int $options = 0): string
+ {
+ return json_encode(['value' => "\xB1\x31"], $options);
+ }
+ };
+
+ $this->assertSame('{"value":"�1"}', Js::encode($jsonable));
+ }
+
public function testArrays()
{
$this->assertEquals(
diff --git a/tests/Support/SupportServiceProviderTest.php b/tests/Support/SupportServiceProviderTest.php
index 4f5b9c849..bffd2f10b 100644
--- a/tests/Support/SupportServiceProviderTest.php
+++ b/tests/Support/SupportServiceProviderTest.php
@@ -5,8 +5,10 @@
namespace Hypervel\Tests\Support;
use Hypervel\Config\Repository as ConfigRepository;
+use Hypervel\Filesystem\Filesystem;
use Hypervel\Foundation\Application;
use Hypervel\Support\ServiceProvider;
+use Hypervel\Testing\ParallelTesting;
use Hypervel\Tests\TestCase;
use Mockery as m;
@@ -420,7 +422,10 @@ public function testLoadTranslationsFromWithNamespace()
public function testCanRemoveProvider()
{
- $tempFile = sys_get_temp_dir() . '/hypervel_test_providers_' . getmypid() . '.php';
+ $tempDirectory = ParallelTesting::tempDir('SupportServiceProviderTest-remove');
+ mkdir($tempDirectory, 0777, true);
+
+ $tempFile = $tempDirectory . '/providers.php';
try {
file_put_contents(
@@ -434,6 +439,7 @@ public function testCanRemoveProvider()
];
PHP
);
+ chmod($tempFile, 0640);
// Strict mode — should delete nothing (partial match doesn't work)
ServiceProvider::removeProviderFromBootstrapFile('TelescopeServiceProvider', $tempFile, true);
@@ -458,8 +464,44 @@ public function testCanRemoveProvider()
];
PHP, trim(file_get_contents($tempFile)));
+ $this->assertSame(0640, fileperms($tempFile) & 0777);
+ } finally {
+ (new Filesystem)->deleteDirectory($tempDirectory);
+ }
+ }
+
+ public function testCanAddProviderAndPreserveFileMode(): void
+ {
+ $tempDirectory = ParallelTesting::tempDir('SupportServiceProviderTest-add');
+ mkdir($tempDirectory, 0777, true);
+
+ $tempFile = $tempDirectory . '/providers.php';
+
+ try {
+ file_put_contents($tempFile, <<<'PHP'
+assertTrue(ServiceProvider::addProviderToBootstrapFile(
+ 'App\Providers\TelescopeServiceProvider',
+ $tempFile,
+ ));
+ $this->assertStringEqualsStringIgnoringLineEndings(<<<'PHP'
+assertSame(0640, fileperms($tempFile) & 0777);
} finally {
- @unlink($tempFile);
+ (new Filesystem)->deleteDirectory($tempDirectory);
}
}
}
diff --git a/tests/Support/SupportStrTest.php b/tests/Support/SupportStrTest.php
index 7c566922d..c73572584 100644
--- a/tests/Support/SupportStrTest.php
+++ b/tests/Support/SupportStrTest.php
@@ -6,7 +6,9 @@
use Countable;
use DateTimeImmutable;
+use DateTimeInterface;
use Exception;
+use Hypervel\Container\Container;
use Hypervel\Support\Str;
use Hypervel\Tests\Support\Fixtures\StringableObjectStub;
use Hypervel\Tests\TestCase;
@@ -19,6 +21,26 @@
class SupportStrTest extends TestCase
{
+ public function testTransReturnsStringable(): void
+ {
+ $originalContainer = Container::getInstance();
+ Container::setInstance($container = new Container);
+ $container->instance('translator', new class {
+ public function get(string $key, array $replace = [], ?string $locale = null): string
+ {
+ return str_replace(':name', $replace['name'], $key) . ' ' . $locale;
+ }
+ });
+
+ try {
+ $string = Str::trans('Hello :name', ['name' => 'Taylor'], 'en');
+ } finally {
+ Container::setInstance($originalContainer);
+ }
+
+ $this->assertSame('hello taylor en', (string) $string->lower());
+ }
+
public function testStringCanBeLimitedByWords(): void
{
$this->assertSame('Taylor...', Str::words('Taylor Otwell', 1));
@@ -1026,6 +1048,24 @@ public function testItCanSpecifyAFallbackForARandomStringSequence()
}
}
+ public function testRandomSequenceIsRestoredWhenNormalGenerationThrows(): void
+ {
+ ThrowingSequenceStr::failNextRandomString();
+ ThrowingSequenceStr::createRandomStringsUsingSequence([1 => 'sequence']);
+
+ try {
+ ThrowingSequenceStr::random();
+ $this->fail('Expected random string generation to fail.');
+ } catch (Exception $exception) {
+ $this->assertSame('Random string generation failed.', $exception->getMessage());
+ }
+
+ $this->assertSame(16, strlen(ThrowingSequenceStr::random()));
+ $this->assertSame('sequence', ThrowingSequenceStr::random());
+
+ ThrowingSequenceStr::createRandomStringsNormally();
+ }
+
public function testReplace()
{
$this->assertSame('foo bar hypervel', Str::replace('baz', 'hypervel', 'foo bar baz'));
@@ -1770,6 +1810,25 @@ public function testItCanSpecifyAFallbackForASequence()
}
}
+ public function testUuidSequenceIsRestoredWhenNormalGenerationThrows(): void
+ {
+ $sequenceUuid = Str::uuid();
+ ThrowingSequenceStr::failNextUuid();
+ ThrowingSequenceStr::createUuidsUsingSequence([1 => $sequenceUuid]);
+
+ try {
+ ThrowingSequenceStr::uuid();
+ $this->fail('Expected UUID generation to fail.');
+ } catch (Exception $exception) {
+ $this->assertSame('UUID generation failed.', $exception->getMessage());
+ }
+
+ $this->assertNotSame($sequenceUuid, ThrowingSequenceStr::uuid());
+ $this->assertSame($sequenceUuid, ThrowingSequenceStr::uuid());
+
+ ThrowingSequenceStr::createUuidsNormally();
+ }
+
public function testItCanFreezeUlids()
{
$this->assertNotSame((string) Str::ulid(), (string) Str::ulid());
@@ -1877,6 +1936,25 @@ public function testItCanSpecifyAFallbackForAUlidSequence()
}
}
+ public function testUlidSequenceIsRestoredWhenNormalGenerationThrows(): void
+ {
+ $sequenceUlid = Str::ulid();
+ ThrowingSequenceStr::failNextUlid();
+ ThrowingSequenceStr::createUlidsUsingSequence([1 => $sequenceUlid]);
+
+ try {
+ ThrowingSequenceStr::ulid();
+ $this->fail('Expected ULID generation to fail.');
+ } catch (Exception $exception) {
+ $this->assertSame('ULID generation failed.', $exception->getMessage());
+ }
+
+ $this->assertNotSame($sequenceUlid, ThrowingSequenceStr::ulid());
+ $this->assertSame($sequenceUlid, ThrowingSequenceStr::ulid());
+
+ ThrowingSequenceStr::createUlidsNormally();
+ }
+
public function testPasswordCreation()
{
$this->assertTrue(strlen(Str::password()) === 32);
@@ -2001,6 +2079,19 @@ public function testReplaceMatches()
$this->assertSame('foo baZ baz bar', $result);
}
+ #[RequiresPhpExtension('intl')]
+ public function testCounted(): void
+ {
+ $this->assertSame('1 order', Str::counted('order', 1));
+ $this->assertSame('2 orders', Str::counted('order', 2));
+ $this->assertSame('0 orders', Str::counted('order', 0));
+ $this->assertSame('1 child', Str::counted('child', 1));
+ $this->assertSame('3 children', Str::counted('child', 3));
+ $this->assertSame('1,000 orders', Str::counted('order', 1000));
+ $this->assertSame('1 order', Str::counted('order', ['a']));
+ $this->assertSame('2 orders', Str::counted('order', ['a', 'b']));
+ }
+
#[RequiresPhpExtension('intl')]
public function testPlural(): void
{
@@ -2040,3 +2131,60 @@ public function count(): int
$this->assertSame('UserGroups', Str::pluralPascal('UserGroup', $countable));
}
}
+
+class ThrowingSequenceStr extends Str
+{
+ protected static bool $failNextRandomString = false;
+
+ protected static bool $failNextUuid = false;
+
+ protected static bool $failNextUlid = false;
+
+ public static function failNextRandomString(): void
+ {
+ static::$failNextRandomString = true;
+ }
+
+ public static function failNextUuid(): void
+ {
+ static::$failNextUuid = true;
+ }
+
+ public static function failNextUlid(): void
+ {
+ static::$failNextUlid = true;
+ }
+
+ public static function random(int $length = 16): string
+ {
+ if (static::$randomStringFactory === null && static::$failNextRandomString) {
+ static::$failNextRandomString = false;
+
+ throw new Exception('Random string generation failed.');
+ }
+
+ return parent::random($length);
+ }
+
+ public static function uuid(): Uuid
+ {
+ if (static::$uuidFactory === null && static::$failNextUuid) {
+ static::$failNextUuid = false;
+
+ throw new Exception('UUID generation failed.');
+ }
+
+ return parent::uuid();
+ }
+
+ public static function ulid(?DateTimeInterface $time = null): Ulid
+ {
+ if (static::$ulidFactory === null && static::$failNextUlid) {
+ static::$failNextUlid = false;
+
+ throw new Exception('ULID generation failed.');
+ }
+
+ return parent::ulid($time);
+ }
+}
diff --git a/tests/Support/SupportStringableTest.php b/tests/Support/SupportStringableTest.php
index 2fa9d5c90..5403ad69a 100644
--- a/tests/Support/SupportStringableTest.php
+++ b/tests/Support/SupportStringableTest.php
@@ -125,6 +125,16 @@ public function testIsNotEmpty()
$this->assertTrue($this->stringable('A')->isNotEmpty());
}
+ public function testCounted(): void
+ {
+ $this->assertSame('1 order', (string) $this->stringable('order')->counted(1));
+ $this->assertSame('2 orders', (string) $this->stringable('order')->counted(2));
+ $this->assertSame('0 orders', (string) $this->stringable('order')->counted(0));
+ $this->assertSame('1,000 orders', (string) $this->stringable('order')->counted(1000));
+ $this->assertSame('1 order', (string) $this->stringable('order')->counted(['a']));
+ $this->assertSame('2 orders', (string) $this->stringable('order')->counted(['a', 'b']));
+ }
+
public function testPluralStudly()
{
$this->assertSame('LaraCon', (string) $this->stringable('LaraCon')->pluralStudly(1));
diff --git a/tests/Support/SupportTestingBusFakeTest.php b/tests/Support/SupportTestingBusFakeTest.php
new file mode 100644
index 000000000..4cd951130
--- /dev/null
+++ b/tests/Support/SupportTestingBusFakeTest.php
@@ -0,0 +1,68 @@
+dispatch(new BusFakeJobStub);
+ $fake->assertDispatchedOnce(BusFakeJobStub::class);
+
+ $fake->dispatchNow(new BusFakeJobStub);
+
+ try {
+ $fake->assertDispatchedOnce(BusFakeJobStub::class);
+ $this->fail();
+ } catch (ExpectationFailedException $exception) {
+ $this->assertStringContainsString(
+ 'The expected [' . BusFakeJobStub::class . '] job was pushed 2 times instead of 1 time.',
+ $exception->getMessage()
+ );
+ }
+ }
+
+ #[DataProvider('countAssertionMethods')]
+ public function testCountAssertionsPluralizeFailureMessages(
+ string $dispatchMethod,
+ string $assertionMethod,
+ string $action
+ ): void {
+ $fake = new BusFake(m::mock(QueueingDispatcher::class));
+ $fake->{$dispatchMethod}(new BusFakeJobStub);
+
+ try {
+ $fake->{$assertionMethod}(BusFakeJobStub::class, 2);
+ $this->fail();
+ } catch (ExpectationFailedException $exception) {
+ $this->assertStringContainsString(
+ 'The expected [' . BusFakeJobStub::class . "] {$action} 1 time instead of 2 times.",
+ $exception->getMessage()
+ );
+ }
+ }
+
+ public static function countAssertionMethods(): array
+ {
+ return [
+ ['dispatch', 'assertDispatchedTimes', 'job was pushed'],
+ ['dispatchSync', 'assertDispatchedSyncTimes', 'job was synchronously pushed'],
+ ['dispatchAfterResponse', 'assertDispatchedAfterResponseTimes', 'job was pushed'],
+ ];
+ }
+}
+
+class BusFakeJobStub
+{
+}
diff --git a/tests/Support/SupportTestingMailFakeTest.php b/tests/Support/SupportTestingMailFakeTest.php
new file mode 100644
index 000000000..aaedc5435
--- /dev/null
+++ b/tests/Support/SupportTestingMailFakeTest.php
@@ -0,0 +1,50 @@
+mailFake();
+
+ $fake->to('taylor@hypervel.org')->sendNow(new QueueableMailableStub);
+
+ $fake->assertSent(
+ QueueableMailableStub::class,
+ fn (QueueableMailableStub $mailable): bool => $mailable->hasTo('taylor@hypervel.org')
+ );
+ $fake->assertNotQueued(QueueableMailableStub::class);
+ }
+
+ public function testAssertQueuedTimesCanBeCalledDirectly(): void
+ {
+ $fake = $this->mailFake();
+
+ $fake->queue(new Mailable);
+ $fake->queue(new Mailable);
+
+ $fake->assertQueuedTimes(Mailable::class, 2);
+ }
+
+ private function mailFake(): MailFake
+ {
+ $manager = m::mock(MailManager::class);
+ $manager->shouldReceive('getDefaultDriver')->once()->andReturn('smtp');
+
+ return new MailFake($manager);
+ }
+}
+
+class QueueableMailableStub extends Mailable implements ShouldQueue
+{
+}
diff --git a/tests/Support/ValidatedInputTest.php b/tests/Support/ValidatedInputTest.php
index 3039c799c..65b5298e6 100644
--- a/tests/Support/ValidatedInputTest.php
+++ b/tests/Support/ValidatedInputTest.php
@@ -4,6 +4,7 @@
namespace Hypervel\Tests\Support;
+use Hypervel\Http\UploadedFile;
use Hypervel\Support\Carbon;
use Hypervel\Support\Collection;
use Hypervel\Support\Stringable;
@@ -495,6 +496,22 @@ public function testEnumsMethod()
$this->assertEmpty($input->enums('invalid_enum_value', StringBackedEnum::class));
}
+ public function testFileMethod(): void
+ {
+ $file = $this->createStub(UploadedFile::class);
+
+ $input = new ValidatedInput([
+ 'name' => 'Taylor',
+ 'avatar' => $file,
+ ]);
+
+ $this->assertSame($file, $input->file('avatar'));
+ $this->assertNull($input->file('name'));
+ $this->assertNull($input->file('missing'));
+ $this->assertSame('default', $input->file('missing', 'default'));
+ $this->assertSame('default', $input->file('name', 'default'));
+ }
+
public function testCollectMethod()
{
$input = new ValidatedInput(['users' => [1, 2, 3]]);
diff --git a/tests/Support/XmlTest.php b/tests/Support/XmlTest.php
index a41fac4c8..f074061d0 100644
--- a/tests/Support/XmlTest.php
+++ b/tests/Support/XmlTest.php
@@ -10,7 +10,7 @@
class XmlTest extends TestCase
{
- public function testToArray()
+ public function testToArray(): void
{
$xml = '';
$data = [
@@ -20,7 +20,7 @@ public function testToArray()
$this->assertSame($data, Xml::toArray($xml));
}
- public function testToArrayException()
+ public function testToArrayException(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Syntax error.');
@@ -28,7 +28,7 @@ public function testToArrayException()
Xml::toArray('xxxxx');
}
- public function testToXml()
+ public function testToXml(): void
{
$xml = '';
$data = [
@@ -38,7 +38,18 @@ public function testToXml()
$this->assertSame(Xml::toXml(Xml::toArray($xml), null, 'xml'), Xml::toXml($data, null, 'xml'));
}
- public function testXmlFailed()
+ public function testNestedNumericKeysUseValidElementNames(): void
+ {
+ $xml = Xml::toXml([
+ ['name' => 'first'],
+ ['name' => 'second'],
+ ], root: 'items');
+
+ $this->assertStringContainsString('first', $xml);
+ $this->assertStringContainsString('second', $xml);
+ }
+
+ public function testXmlFailed(): void
{
$this->expectException(InvalidArgumentException::class);
Xml::toArray('{"hype');
diff --git a/tests/Testing/PHPUnit/AfterEachTestSubscriberNonCoroutineTest.php b/tests/Testing/PHPUnit/AfterEachTestSubscriberNonCoroutineTest.php
new file mode 100644
index 000000000..d689bf5b2
--- /dev/null
+++ b/tests/Testing/PHPUnit/AfterEachTestSubscriberNonCoroutineTest.php
@@ -0,0 +1,32 @@
+assertNotEmpty(CoroutineContext::getContainer());
+
+ (new class extends AfterEachTestSubscriber {
+ public function flushFrameworkStateForTest(): void
+ {
+ $this->flushFrameworkState();
+ }
+ })->flushFrameworkStateForTest();
+
+ $this->assertSame([], CoroutineContext::getContainer());
+ }
+}
diff --git a/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php b/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php
index ca84091f7..801d2f6d9 100644
--- a/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php
+++ b/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php
@@ -6,6 +6,7 @@
use Hypervel\Contracts\Pool\ConnectionInterface;
use Hypervel\Database\Eloquent\Factories\Factory as EloquentFactory;
+use Hypervel\Encryption\Commands\KeyGenerateCommand;
use Hypervel\Foundation\Testing\DatabaseConnectionResolver;
use Hypervel\Http\Client\Factory as HttpFactory;
use Hypervel\Http\Client\PendingRequest;
@@ -21,10 +22,14 @@
use Hypervel\Testing\PHPUnit\AfterEachTestCleanup;
use Hypervel\Testing\PHPUnit\AfterEachTestSubscriber;
use Hypervel\Tests\TestCase;
+use Laravel\SerializableClosure\SerializableClosure;
+use Laravel\SerializableClosure\Serializers\Native;
+use Laravel\SerializableClosure\Serializers\Signed;
use Mockery as m;
use Mockery\Exception\InvalidCountException;
use Override;
use ReflectionClass;
+use ReflectionProperty;
use RuntimeException;
class AfterEachTestSubscriberTest extends TestCase
@@ -114,6 +119,59 @@ public function flushFrameworkStateForTest(): void
}
}
+ public function testFrameworkCleanupFlushesSerializableClosureGlobals(): void
+ {
+ SerializableClosure::setSecretKey('secret');
+ SerializableClosure::transformUseVariablesUsing(static fn (array $variables): array => $variables);
+ SerializableClosure::resolveUseVariablesUsing(static fn (array $variables): array => $variables);
+
+ $this->assertNotNull(Signed::$signer);
+ $this->assertNotNull(Native::$transformUseVariables);
+ $this->assertNotNull(Native::$resolveUseVariables);
+
+ $subscriber = new class extends AfterEachTestSubscriber {
+ public function flushFrameworkStateForTest(): void
+ {
+ $this->flushFrameworkState();
+ }
+ };
+
+ try {
+ $subscriber->flushFrameworkStateForTest();
+
+ $this->assertNull(Signed::$signer);
+ $this->assertNull(Native::$transformUseVariables);
+ $this->assertNull(Native::$resolveUseVariables);
+ } finally {
+ SerializableClosure::setSecretKey(null);
+ SerializableClosure::transformUseVariablesUsing(null);
+ SerializableClosure::resolveUseVariablesUsing(null);
+ }
+ }
+
+ public function testFrameworkCleanupFlushesKeyGenerateCommandProhibition(): void
+ {
+ KeyGenerateCommand::prohibit();
+ $prohibited = new ReflectionProperty(KeyGenerateCommand::class, 'prohibitedFromRunning');
+
+ $this->assertTrue($prohibited->getValue());
+
+ $subscriber = new class extends AfterEachTestSubscriber {
+ public function flushFrameworkStateForTest(): void
+ {
+ $this->flushFrameworkState();
+ }
+ };
+
+ try {
+ $subscriber->flushFrameworkStateForTest();
+
+ $this->assertFalse($prohibited->getValue());
+ } finally {
+ KeyGenerateCommand::flushState();
+ }
+ }
+
public function testFlushStateAfterTestRunsCustomCallbacksBeforeFrameworkCleanup(): void
{
$subscriber = new class extends AfterEachTestSubscriber {