diff --git a/composer.json b/composer.json index 142acd357..960a69d65 100644 --- a/composer.json +++ b/composer.json @@ -352,6 +352,7 @@ "Hypervel\\Redis\\RedisServiceProvider", "Hypervel\\Reverb\\ReverbServiceProvider", "Hypervel\\Routing\\RoutingServiceProvider", + "Hypervel\\Sanctum\\SanctumServiceProvider", "Hypervel\\Server\\ServerServiceProvider", "Hypervel\\ServerProcess\\ServerProcessServiceProvider", "Hypervel\\Session\\SessionServiceProvider", diff --git a/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md b/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md index ce1a50693..1969b17f8 100644 --- a/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md +++ b/docs/plans/2026-07-12-0900-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-0915-framework-coroutine-state-lifecycle-audit-ledger.md). -- **Active package or work unit:** None. `translation` is complete; detail plan `2026-08-06-0925-translation-correctness-current-parity-and-worker-lifecycles.md`. -- **Ledger entries required for the active work:** None. The completed Translation work is recorded under `Complete Translation correctness, current parity, and worker lifecycles`, with its cross-package findings recorded at their owning package entries. -- **Pending revalidation carried into the active work:** None. The completed View audit owns `ComponentAttributeBag::data()`; the completed Translation audit owns `__()`'s conditional type. +- **Active package or work unit:** None. `sanctum` is complete; detail plan `2026-08-07-1302-sanctum-correctness-cache-settlement-and-current-parity.md`. +- **Ledger entries required for the active work:** None. The completed Sanctum work is recorded under `Complete Sanctum correctness, cache settlement, and current parity`, with its Database and Auth findings also recorded at their owning package entries. +- **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. @@ -1053,11 +1053,11 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `queue-11` | `queue` | `events`, `queue`, and `broadcasting` (revalidation complete) | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `queue-11` | | `queue-12` | `bus`, `queue` | `events`, `bus`, `queue`, and `broadcasting` (revalidation complete) | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `queue-12` | | `foundation-01` | `foundation` | `support` and `foundation` (revalidation complete) | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `foundation-01` | -| `support-02` | `support` | `auth` (revalidation complete), `broadcasting` (revalidation complete), `bus` (revalidation complete), `cache` (revalidation complete), `concurrency`, `console` (revalidation complete), `container`, `contracts`, `cookie`, `database` (revalidation complete), `events`, `filesystem` (revalidation complete), `foundation` (revalidation complete), `hashing` (revalidation complete), `horizon` (revalidation complete), `inertia`, `jwt`, `log`, `mail`, `notifications` (revalidation complete), `permission`, `pipeline`, `queue` (revalidation complete), `redis` (revalidation complete), `reverb` (revalidation complete), `routing` (revalidation complete), `sanctum`, `scout`, `session` (revalidation complete), `socialite`, `telescope`, `testbench`; `translation` (revalidation complete); later full remaining 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-0920-framework-enum-identifier-contracts.md` | +| `support-02` | `support` | `auth` (revalidation complete), `broadcasting` (revalidation complete), `bus` (revalidation complete), `cache` (revalidation complete), `concurrency`, `console` (revalidation complete), `container`, `contracts`, `cookie`, `database` (revalidation complete), `events`, `filesystem` (revalidation complete), `foundation` (revalidation complete), `hashing` (revalidation complete), `horizon` (revalidation complete), `inertia`, `jwt`, `log`, `mail`, `notifications` (revalidation complete), `permission`, `pipeline`, `queue` (revalidation complete), `redis` (revalidation complete), `reverb` (revalidation complete), `routing` (revalidation complete), `sanctum` (revalidation complete), `scout`, `session` (revalidation complete), `socialite`, `telescope`, `testbench`; `translation` (revalidation complete); later full remaining 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-0920-framework-enum-identifier-contracts.md` | | `macroable-03` | `macroable` | `cookie`, `log`, and `notifications` (revalidation complete); later full `jwt` audit | `Complete Macroable callable and test-state handling`; finding `macroable-03` | | `auth-01` | `support`, `auth` | `auth` (revalidation complete) | `Correct Support utility boundaries and authentication timing isolation`; finding `auth-01` | | `encryption-03` | `encryption` | `contracts`, `support`, `filesystem`, and `foundation` (revalidation complete) | `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` | +| `sanctum-01` | `sanctum` | `encryption` and `sanctum` (revalidation complete) | `Harden encryption rotation, key publication, and global lifecycle state`; finding `sanctum-01` | | `process-02` | `process` | `concurrency` (revalidation complete) | `Make Process callbacks and pools failure-safe`; finding `process-02` | | `server-process-10` | `server-process` | `foundation` (revalidation complete) | `Make custom server processes failure-safe`; finding `server-process-10` | | `signal-05` | `contracts`, `signal` | `server-process` (revalidation complete) | `Complete Signal handler reliability, public APIs, and deployment guidance`; finding `signal-05` | @@ -1110,7 +1110,7 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `telescope-01` | `telescope` | `redis` (revalidation complete); later full `telescope` audit | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `telescope-01` | | `telescope-02` | `telescope` | `redis` (revalidation complete); later full `telescope` audit | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `telescope-02` | | `sentry-01` | `sentry` | `redis` (revalidation complete); later full `sentry` audit | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `sentry-01` | -| `cache-04` | `cache` | `auth` (full-audit revalidation complete), `sanctum` and `testbench` (revalidation complete); later full remaining consumer audits | `Complete Cache parity, cleanup, permanence, and tagged ownership`; finding `cache-04` | +| `cache-04` | `cache` | `auth`, `sanctum`, and `testbench` (revalidation complete); later full remaining consumer audits | `Complete Cache parity, cleanup, permanence, and tagged ownership`; finding `cache-04` | | `filesystem-12` | `filesystem` | `session` (revalidation complete) | `Complete Session lifecycles, persistence, and current Laravel parity`; finding `filesystem-12` | | `session-23` | `cache` | `session` (revalidation complete) | `Complete Session lifecycles, persistence, and current Laravel parity`; finding `session-23` | | `contracts-09` | `contracts` | `foundation` and `broadcasting` (revalidation complete) | `Complete Queue pooling, payload durability, and current Laravel parity`; finding `contracts-09` | @@ -1152,7 +1152,26 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `collections-15` | `collections` | `collections` and `pagination` (revalidation complete) | `Complete Pagination correctness, current parity, and query contracts`; finding `collections-15` | | `support-32` | `support` | `support` and `pagination` (revalidation complete) | `Complete Pagination correctness, current parity, and query contracts`; finding `support-32` | | `support-33` | `support` | `support` and `pagination` (revalidation complete) | `Complete Pagination correctness, current parity, and query contracts`; finding `support-33` | -| `sanctum-02` | `sanctum` | `sanctum` (targeted correction complete); later full `sanctum` audit | `Complete Pagination correctness, current parity, and query contracts`; finding `sanctum-02` | +| `sanctum-02` | `sanctum` | `sanctum` (revalidation complete) | `Complete Pagination correctness, current parity, and query contracts`; finding `sanctum-02` | +| `sanctum-03` | `sanctum` | `sanctum` (revalidation complete) | `Complete Sanctum correctness, cache settlement, and current parity`; finding `sanctum-03` | +| `sanctum-04` | `sanctum` | `sanctum` (revalidation complete) | `Complete Sanctum correctness, cache settlement, and current parity`; finding `sanctum-04` | +| `sanctum-05` | `sanctum` | `sanctum` (revalidation complete) | `Complete Sanctum correctness, cache settlement, and current parity`; finding `sanctum-05` | +| `sanctum-06` | `sanctum` | `sanctum` (revalidation complete) | `Complete Sanctum correctness, cache settlement, and current parity`; finding `sanctum-06` | +| `sanctum-07` | `sanctum` | `sanctum` (revalidation complete) | `Complete Sanctum correctness, cache settlement, and current parity`; finding `sanctum-07` | +| `sanctum-08` | `sanctum` | `sanctum` (revalidation complete) | `Complete Sanctum correctness, cache settlement, and current parity`; finding `sanctum-08` | +| `sanctum-09` | `sanctum` | `sanctum` (revalidation complete) | `Complete Sanctum correctness, cache settlement, and current parity`; finding `sanctum-09` | +| `sanctum-10` | `sanctum` | `sanctum` (revalidation complete) | `Complete Sanctum correctness, cache settlement, and current parity`; finding `sanctum-10` | +| `sanctum-12` | `sanctum` | `sanctum` (revalidation complete) | `Complete Sanctum correctness, cache settlement, and current parity`; finding `sanctum-12` | +| `sanctum-13` | `sanctum` | `sanctum` (revalidation complete) | `Complete Sanctum correctness, cache settlement, and current parity`; finding `sanctum-13` | +| `sanctum-14` | `sanctum` | `sanctum` (revalidation complete) | `Complete Sanctum correctness, cache settlement, and current parity`; finding `sanctum-14` | +| `sanctum-15` | `sanctum` | `sanctum` (revalidation complete) | `Complete Sanctum correctness, cache settlement, and current parity`; finding `sanctum-15` | +| `sanctum-16` | `sanctum` | `sanctum` (revalidation complete) | `Complete Sanctum correctness, cache settlement, and current parity`; finding `sanctum-16` | +| `sanctum-17` | `sanctum` | `sanctum` (revalidation complete) | `Complete Sanctum correctness, cache settlement, and current parity`; finding `sanctum-17` | +| `sanctum-18` | `sanctum` | `sanctum` (revalidation complete) | `Complete Sanctum correctness, cache settlement, and current parity`; finding `sanctum-18` | +| `database-26` | `database` | `database`, `auth`, and `sanctum` (revalidation complete) | `Complete Sanctum correctness, cache settlement, and current parity`; finding `database-26` | +| `database-27` | `database` | `database`, `bus`, `foundation`, `queue`, `events`, `mail`, `notifications`, `broadcasting`, `scout`, and `sanctum` (revalidation complete) | `Complete Sanctum correctness, cache settlement, and current parity`; finding `database-27` | +| `database-28` | `database` | `database`, `auth`, and `sanctum` (revalidation complete) | `Complete Sanctum correctness, cache settlement, and current parity`; finding `database-28` | +| `auth-18` | `auth` | `auth` and `sanctum` (revalidation complete) | `Complete Sanctum correctness, cache settlement, and current parity`; finding `auth-18` | | `api-client-01` | `api-client` | `api-client` (targeted correction complete); later full `api-client` audit | `Complete Pagination correctness, current parity, and query contracts`; finding `api-client-01` | | `database-24` | `database` | `database` and `pagination` (revalidation complete) | `Complete Pagination correctness, current parity, and query contracts`; finding `database-24` | | `database-25` | `database` | `database` and `pagination` (revalidation complete) | `Complete Pagination correctness, current parity, and query contracts`; finding `database-25` | @@ -1285,7 +1304,7 @@ The order is lower-level first where practical. Hypervel has cross-cutting depen - [x] `translation` - [x] `pagination` - [ ] `socialite` -- [ ] `sanctum` +- [x] `sanctum` - [ ] `fortify` - [ ] `passkeys` - [ ] `permission` diff --git a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md index 81ba4bc9c..e306ddd26 100644 --- a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md +++ b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md @@ -87,8 +87,8 @@ Append package entries in checklist order. Keep each entry compact but complete | `contracts-07` | Userland footgun | Minor | High | Several concrete worker-singleton mutators lack lifecycle warnings | Add warnings at concrete lifetime boundaries; keep generic contracts implementation-neutral except for universal native/process invariants | | `contracts-08` | Improvement | Minor | High | The package lacks required upstream references, accurate nullable model documentation, method docs, and several native test/doc return types | Complete the package documentation while preserving concise title-only method docs | | `contracts-09` | Contract defect | Minor | High | The core Broadcaster contract requires optional channel enumeration that only concrete Hypervel broadcasters provide | Remove the optional capability from the contract while retaining it on concrete broadcasters, the proxy, and facade | -| `contracts-10` | Contract defect | Minor | High | The Mail queue contract rejects enum identifiers supported by the framework's queue boundary | Accept `UnitEnum|string|null` where queue selection is optional and `UnitEnum|string` on `queueOn()` / `laterOn()` across the contract, concrete mailer, fake, and facade | -| `contracts-11` | Contract defect | Minor | High | The Mail contract describes message callbacks as `mixed` even though the supported runtime boundary is `Closure|string` | Use the truthful callback union and revalidate Mail plus Console's scheduled-output consumer | +| `contracts-10` | Contract defect | Minor | High | The Mail queue contract rejects enum identifiers supported by the framework's queue boundary | Accept `UnitEnum\|string\|null` where queue selection is optional and `UnitEnum\|string` on `queueOn()` / `laterOn()` across the contract, concrete mailer, fake, and facade | +| `contracts-11` | Contract defect | Minor | High | The Mail contract describes message callbacks as `mixed` even though the supported runtime boundary is `Closure\|string` | Use the truthful callback union and revalidate Mail plus Console's scheduled-output consumer | | `notifications-07` | Contract defect | Major | High | The Notification Factory rejects a supported single notifiable even though every implementation and the Dispatcher accepts it | Widen Factory notifiables to `mixed`, retain its two-parameter `sendNow()`, and keep channels on the Dispatcher contract | - **Important rejected concerns:** Do not rewrite validation around only `ValidationRule`; Laravel's deprecated contracts remain its live internal execution protocol, userland already receives the modern API, and a rewrite would add permanent synchronization cost without behavior gain. Do not remove `Console\Application::add()`: Symfony deprecated its underlying method, but Laravel deliberately retains, uses, and tests a non-deprecated wrapper over `addCommand()`. Do not add orphan Image contracts before Hypervel has a coherent Image package. Do not relocate the Monolog-specific context contract without a separately approved API redesign. Do not add optional dependencies merely because they appear only in lazy parameter/return types. Do not make public `View::share()` request-dependent or add a public request-sharing API. Do not keep a throwing `Request::get()` tombstone; record the intentional omission so static analysis rejects it. @@ -519,11 +519,11 @@ Append package entries in checklist order. Keep each entry compact but complete | `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; the full Filesystem audit found no evidence for broader generic-write redaction | -| `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 | +| `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; the completed Sanctum audit revalidated the correction | - **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. The full Filesystem audit retained the focused sensitive replacement boundary and found no evidence for broader generic-write redaction; route the helper boundary into the later full Foundation audit, and route `sanctum-01` back into the later full Sanctum audit. +- **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. The full Filesystem audit retained the focused sensitive replacement boundary and found no evidence for broader generic-write redaction; route the helper boundary into the later full Foundation audit. The completed Sanctum audit revalidated `sanctum-01`. - **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. - **Later Routing revalidation:** `routing-12` makes Encryption's provider read the owning application config through typed required getters, preserving `MissingAppKeyException` for absent or empty keys and retaining previous-key rotation under merge-disabled configuration. - **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. @@ -1215,7 +1215,7 @@ Append package entries in checklist order. Keep each entry compact but complete - **Performance and compatibility:** Normal Database queries gain no container lookup, context operation, lock, retry, logging, yield, or registry. Stable model construction adds one static owner-map `isset()`, and first boot alone reads the coroutine ID and may use the existing Mutex. Redis release adds one local extension-state read and one boolean; same-connection publication adds one owner-ID comparison only when publishing a pin. Per-key cast merging and indexed raw-SQL substitution remove work. SQLite normalization occurs at setup, lifecycle events are cold task/start boundaries, and exhaustive cleanup is exceptional or terminal. Public Laravel APIs remain intact; supported current APIs are restored, while internal Swoole adaptations remain at their lowest owners. - **Implementation:** Added exact task and pre-fork lifecycle events and Database/Redis listeners; replaced reflective task cleanup with exact resolver/proxy ownership; deduplicated Redis terminal defers without delaying callback release; made Redis event cleanup, WATCH, native DISCARD, mode checks, purge, and fork cleanup truthful; centralized SQLite classification and serialized shared-memory ownership; corrected RefreshDatabase and parallel/Testbench consumers; repaired transaction publication, retry, rollback, callback, disconnect, and query-log state; made first Eloquent boot coroutine-safe; ported the complete accepted current Laravel Database surface and both approved performance corrections; made the connection contract expose the implementation's existing rollback-level parameter; completed split metadata, provenance, omission markers, facade metadata, and concise user documentation; and removed every superseded listener, literal classifier, dependency, comment, and test assumption. - **Regression tests:** Deterministic coverage spans task and fork failure precedence; exact Database and Redis wrapper ownership; copied-context and callback-immediate Redis release; real MULTI/PIPELINE/WATCH/DISCARD state; full SQLite URI classification, canonical refresh, mixed RefreshDatabase ownership, and one-owner concurrency; transaction begin/commit/rollback/disconnect and manager-callback failures; Queue's typed connection consumer rolling back a failed-job transaction to level zero; retry suppression and primary-failure preservation; recursive, concurrent, failed, and post-publication model boot; all supported current Query, Eloquent, Schema, migration, connector, provider, exception, metadata, facade, documentation-facing call shapes, and external MySQL, MariaDB, PostgreSQL, SQLite, Redis, and Valkey behavior. -- **Cross-package revalidation:** The work closes carried `database-01` through `database-04`, `redis-01` and `redis-02`, `pool-04`, `pool-05`, `pool-08`, `context-04`, `support-02`, `foundation-06`, and the Database side of `database-03`. Core and Server own the new lifecycle producers; the full Redis audit revalidated `redis-03` through `redis-08`; Foundation, Testing, and Testbench consume the SQLite and transaction-test boundaries; Scout and NestedSet consume the corrected model-boot publication; Telescope's aggregate SQL expectation follows the corrected grammar. The later Queue audit added `database-14` and revalidated the rollback-level contract through its failed-job timeout cleanup. The Eloquent identity work adds `database-15` through `database-20` and the Database side of `queue-41`, correcting stored-row comparison, relation-key normalization, partial persistence identity, compound pivots, collection membership, soft-delete state, and queueable collection publication without changing the pooled connection lifecycle. The Scout audit adds `database-21` through `database-23`: shared chunk owners reject nonpositive counts, `CollectedBy` uses constructor-owned positional/named arguments before caching, and descending cursors accept the same string/integer domain as ascending cursors. The Pagination audit adds `database-24` and `database-25`: both relation families accept cursor objects, Eloquent restores concrete generic paginator results, and Query's cursor per-page boundary states its existing non-null requirement. Database, Scout, and Pagination consumer coverage and the complete gate are green. +- **Cross-package revalidation:** The work closes carried `database-01` through `database-04`, `redis-01` and `redis-02`, `pool-04`, `pool-05`, `pool-08`, `context-04`, `support-02`, `foundation-06`, and the Database side of `database-03`. Core and Server own the new lifecycle producers; the full Redis audit revalidated `redis-03` through `redis-08`; Foundation, Testing, and Testbench consume the SQLite and transaction-test boundaries; Scout and NestedSet consume the corrected model-boot publication; Telescope's aggregate SQL expectation follows the corrected grammar. The later Queue audit added `database-14` and revalidated the rollback-level contract through its failed-job timeout cleanup. The Eloquent identity work adds `database-15` through `database-20` and the Database side of `queue-41`, correcting stored-row comparison, relation-key normalization, partial persistence identity, compound pivots, collection membership, soft-delete state, and queueable collection publication without changing the pooled connection lifecycle. The Scout audit adds `database-21` through `database-23`: shared chunk owners reject nonpositive counts, `CollectedBy` uses constructor-owned positional/named arguments before caching, and descending cursors accept the same string/integer domain as ascending cursors. The Pagination audit adds `database-24` and `database-25`: both relation families accept cursor objects, Eloquent restores concrete generic paginator results, and Query's cursor per-page boundary states its existing non-null requirement. The Sanctum audit adds `database-26`, forwarding named connection ownership into commit and rollback callback registration; `database-27`, documenting the retained ambient unnamed-callback rule precisely; and `database-28`, marking transaction-manager removal as tests-only because its state survives pool release. Database, Auth, Sanctum, Bus, Foundation, Queue, Events, Mail, Notifications, Broadcasting, Scout, and Pagination consumer coverage is green. - **Validation and review:** Every changed test file and affected package group passed during implementation. The final `composer fix` gate changed no formatting, both PHPStan configurations passed, and the complete parallel components, Testbench package, and dogfood suites passed. Database and Redis split manifests validate, `git diff --check`, package-checklist parity, broad stale-reference/classifier/dependency scans, and a fresh full-diff caller/callee, lifecycle, transaction, API, documentation, hot-path, retained-state, and overengineering self-review are complete. Independent post-implementation code review is signed off with no remaining findings. - **Laravel-facing result:** Current supported Laravel Database APIs, signatures, member ordering, tests, and task-first documentation are restored. Intentional differences are limited to Swoole/coroutine ownership, pooled-connection safety, truthful contract corrections, unsupported drivers/dynamic connections, and deliberate omission of directly deprecated forwarding. Redis's Hypervel/Hyperf-derived internals retain their public Laravel-shaped command surface while fixing lifecycle and native-state defects. - **Assessment:** Every accepted Database finding and linked Redis ownership correction is implemented at its lowest owner. The result removes reflective and duplicated cleanup, repeated defer retention, inconsistent classifiers, false shared-PDO capacity, stale manager state, and quadratic or over-broad work while adding only the owner-approved noise-level correctness checks. It contains no workaround, speculative mechanism, compatibility shim, hot-path synchronization, unresolved accepted defect, or stale superseded path. @@ -1678,7 +1678,7 @@ Append package entries in checklist order. Keep each entry compact but complete | `mail-07`, `mail-08`, `mail-11`–`mail-15`, `http-27` | Remove stale transport fallbacks and false dependencies/suppressions, correct split metadata and native-resource docs, document SES v2-only and pooled transport behavior, and restore concise canonical README ordering. | | `mail-10` | Move Mail tests onto framework bases and worker-safe temporary directories, and complete truthful native test typing without duplicate cleanup. | | `support-28`, `support-30`, `support-31` | Make MailFake intercept every delivery path with one-shot selection and truthful queue/recipient/assertion behavior; narrow NotificationFake's callback domain and port current fake coverage. | -| `contracts-11` | Replace unsupported `mixed` callbacks on `raw()`, `html()`, and `plain()` with the actual `Closure|string` boundary and revalidate Console's scheduled-output consumer. | +| `contracts-11` | Replace unsupported `mixed` callbacks on `raw()`, `html()`, and `plain()` with the actual `Closure\|string` boundary and revalidate Console's scheduled-output consumer. | - **Upstream inventory:** Current Laravel supplied the final source and tests. Originating framework PRs `#60865`, `#59443`, `#60886`, `#58686`, and `#51990` were used only to identify the complete delayed-queue, no-attachment, SES tenant, URL predicate, and callback-ordering surfaces. - **Important rejected concerns:** Do not add transport retries, health checks, poisoned-resource policy, attachment streaming machinery, Filesystem capability/reflection wrappers, pool-proxy magic forwarding, fake delay state, callback-order modes, a notification-specific duplicate ordering test, a Support package-graph redesign, or SES v1 compatibility. Retain intentional Markdown fallbacks and the protected Laravel provider extension point. Failed attachment-data reads have no valid fallback and continue to fail at the existing typed/Symfony boundary. @@ -1706,11 +1706,12 @@ Append package entries in checklist order. Keep each entry compact but complete | `auth-13` | Declare the split Auth package's complete direct runtime dependency set and provider discovery metadata, with executable metadata coverage. | | `auth-14` | Revalidate the existing Auth README and user-cache guidance without changing already-correct documentation. | | `auth-15`, `auth-16` | Add focused current-Laravel integration coverage for dispatcher rebinding, password reset, real rehashing, and callable forms, and redact the exact eleven credential or key-bearing parameters. | +| `auth-18` | Settle cached Eloquent user invalidation on the mutated model's connection while preserving event-time identifiers and commit-time descriptor discovery. | - **Worker and coroutine ownership:** AuthManager, resolved guards, Gate, password brokers, and providers remain worker-lifetime objects. Guard selection, broker overrides, user state, and timed operation state remain coroutine- or operation-local. Event rebinding updates existing owners without resolving unused managers, and retained Eloquent descriptors contain immutable keyspace data rather than provider instances. Broker rebinding and enum normalization complete existing package patterns; the concrete broker check preserves the manager's protected custom-resolution extension point. - **Important rejected concerns:** Do not add a callable parser, unbounded string cache, generic Auth event helper, dispatcher registry, broker reconstruction, listener unregistration, cache-descriptor refcounts, provider ownership objects, settings object, token normalizer, compatibility wrapper, or broad integration-suite clone. Do not mark Recaller sensitive without a demonstrated exception trace, move the reset-link event after custom callbacks, or change AuthorizationException without a supported failing caller. - **Implementation and boundaries:** Token validation, callable reflection, optional event dispatch, event-fake restoration, remember-token cleanup, provider model switching, enum identifiers, configuration ownership, sensitive parameters, metadata, facades, and verification guidance now have one truthful owner. Optional-event guards comply with the repository's required `hasListeners()` convention. Superseded guest-closure and cached-model fields, duplicate top-level defaults, stale types, and false split metadata are removed. -- **Cross-package revalidation:** `auth-01`, `auth-02`, `container-09`, `support-02`, and `cache-04` remain satisfied by the final Auth design. Auth owns `auth-12`; Fortify's two-factor credential path now consumes the shipped top-level hashing default without duplicating it and retains later full-package audit eligibility. No consumer-local workaround or deferred Auth item remains. +- **Cross-package revalidation:** `auth-01`, `auth-02`, `container-09`, `support-02`, and `cache-04` remain satisfied by the final Auth design. Auth owns `auth-12`; Fortify's two-factor credential path now consumes the shipped top-level hashing default without duplicating it and retains later full-package audit eligibility. The later Sanctum audit completes `auth-18` at Auth's cache owner and consumes Database's named-connection callback correction without adding Auth transaction state. No consumer-local workaround or deferred Auth item remains. - **Later Routing revalidation:** `routing-18` revalidates `RedirectIfAuthenticated` against indexed compiled method lookups and removes the suppression made unnecessary by the truthful route-map contract. - **Regression tests:** Deterministic coverage proves hashed and zero-valued tokens, ordered lazy token sources, every callable family, weak-cache cleanup, listener and fake event paths, broker rebinding and restoration, timestamp restoration with original failure identity, old and new model-keyspace invalidation, enum identity and defaulting, config replacement semantics, split metadata, sensitive attributes, real reset wiring, session-dispatcher rebinding, password rehashing, and supported custom-driver callables. - **Performance and complexity:** Ordinary token and enum boundaries add only bounded local type branches; Gate adds one existing cached-listener lookup and avoids unused event allocation and dispatch; password-broker events do the same; cached user lookup keeps one model-string read; and timestamp restoration surrounds an existing database write. No request path gains a lock, retry, extra I/O, serialization layer, network round trip, context slot, container-resolution loop, or unbounded worker-retained state. @@ -1834,7 +1835,7 @@ Append package entries in checklist order. Keep each entry compact but complete - **Architecture and worker ownership:** Paginator instances remain operation-local. Cursor construction owns the flat-parameter invariant across decoded, direct, generated, and userland cursors; malformed decoded arrays retain the existing null result. Three paginator request resolvers and the cursor resolver read only the current `RequestContext`; the lazy view resolver alone captures the worker container and honors rebinding. Those five resolvers and two view names remain explicit worker-static boot/test configuration reset by the existing subscriber. No paginator service, request context slot, lock, registry, or retained request object was added. - **Important rejected concerns:** Do not add cursor signing/encryption, strict base64 mode, recursive payload validation, a generic query-bindability validator, a JSON helper or wrapper exception, a generic reset framework, eager view resolution, broader View/Factory contracts, an `onEachSide` interface/property promise, concrete `UrlWindow` dependency, global PHPStan ignore, uniform per-page normalization, or Scout paginator generics unsupported by engine contracts. Arrays are the only cursor parameter shape that the query builder silently changes; enums and dates are valid bindings, while unsupported objects already fail loudly. A local `UrlWindow` intersection is required because changing the public contract breaks Laravel compatibility and interface mocks, while narrowing the stored property rejects valid assignments at higher analysis levels. - **Upstream inventory and owner routes:** #59699 contributes the malformed-cursor source/tests; #60968 contributes zero-per-page and unconditional cursor-item indexing; all 18 files from #60586 were checked against current source. Collections random types and Validation defaults were already current; Pagination, Support Lottery, and Routing land here. View's `ComponentAttributeBag::data()` type is complete in the merged View audit, and Translation's `__()` conditional type is complete in the Translation audit. -- **Cross-package revalidation:** Completed Collections, Support, Database, Routing, and Scout are revalidated by `collections-15`, `support-32`, `support-33`, `database-24`, `database-25`, `routing-25`, and `scout-41`. Sanctum and API Client receive only `sanctum-02` and `api-client-01`; their later full package audits remain open. No consumer-local workaround or duplicate normalization was added. +- **Cross-package revalidation:** Completed Collections, Support, Database, Routing, and Scout are revalidated by `collections-15`, `support-32`, `support-33`, `database-24`, `database-25`, `routing-25`, and `scout-41`. The completed Sanctum audit revalidated `sanctum-02`; API Client receives `api-client-01` and its later full package audit remains open. No consumer-local workaround or duplicate normalization was added. - **Regression and static coverage:** Counterfactual tests cover raw and encoded malformed cursors, every invalid envelope shape, flat/nested/empty decoded arrays, direct array construction, generation from an array-valued ordered item, mixed cursor and pivot values, backed enums, float-ordered SQLite paging, integer query keys, explicit zero across direct/Query/Eloquent paths, Scout engine inputs, one-read request ownership, concurrent isolation, lazy view rebinding, JSON failures and escape flags, keyed cursor pages, zero per-page, every static slot, provider discovery, relation cursor objects, concrete/generic paginator results, and conditional/iterator types. `src` analysis pins the local `UrlWindow` structural read; focused type fixtures cover Pagination, Database builders/relations, Routing, and Lottery. - **Performance and compatibility:** Four request resolvers remove a duplicate context check and container/request resolution. Added recurring work is one allocation-free, non-recursive scan of the typically one-to-three values at Cursor construction, one required `Collection::values()` normalization per cursor page measured below a microsecond at representative sizes, one integer `max()` per Scout pagination call, and one negligible lazy-resolver return verification. JSON and URL serialization remain one pass; no query, network round trip, lock, yield, retry, polling loop, unbounded cache, or retained worker memory is added. Laravel APIs are preserved or widened except that array-valued cursor parameters now fail before the query builder can silently change them, invalid explicit pages now fail closed before ambient resolver or engine behavior, and Query Builder's cursor per-page parameter is narrowed from nullable to its already-required `int`; explicit null already threw one frame deeper, so valid calls and runtime work are unchanged while the failure moves to the call site. - **Validation and review:** Every changed test file and affected max-level type fixture is green. The authoritative `composer fix` gate passed formatting, both PHPStan configurations, the full parallel suite, Testbench package mode, and dogfood; post-gate review corrections passed focused tests, scoped formatting/analysis, Composer validation, and `git diff --check`. Independent review re-read every changed file, traced all callers, verified retained regressions and hot paths, and signed off after the final bounded corrections. @@ -1875,13 +1876,43 @@ Append package entries in checklist order. Keep each entry compact but complete - **Validation and review:** Changed test files and the combined View, Integration/View, Foundation View, Mail View, facade, and metadata coverage are green. Facades and split metadata have been regenerated and checked. The prior authoritative `composer fix` gate passed formatting, both PHPStan configurations, the complete parallel suite, Testbench package mode, and dogfood. The compile-state follow-up passes the complete View group, both PHPStan configurations, targeted formatting, and `git diff --check`; independent follow-up review verified the final tree and signed off with no remaining finding. - **Assessment:** Every accepted View finding is implemented at its owning boundary without a local workaround, speculative abstraction, stale compatibility path, meaningful hot-path regression, or unintended Laravel API removal. No View TODO, deferred defect, or open workflow step remains. +### Complete Sanctum correctness, cache settlement, and current parity + +- **Status and inspected surface:** Complete; implementation, focused validation, the authoritative gate, fresh self-review, and independent code review are signed off. The audit covered every Sanctum source and test file; Auth and Database cache/transaction owners; Foundation guard and middleware configuration; Cache serialization policy; split/root metadata; package and Boost documentation; current Laravel Sanctum and framework source, tests, documentation, and originating changes; and carried `sanctum-01`, `sanctum-02`, `support-02`, and `cache-04`. The detailed design is recorded in [`2026-08-07-1302-sanctum-correctness-cache-settlement-and-current-parity.md`](2026-08-07-1302-sanctum-correctness-cache-settlement-and-current-parity.md). + +| Findings | Final decision | +|---|---| +| `sanctum-03`, `sanctum-18` | Invalidate token caches only after successful model persistence and transaction settlement, clear pre-created negative entries, and use the configured token model's primary key and late-static cache namespace throughout. | +| `sanctum-17` | Give `HasApiTokens::tokens()` a dedicated protected relation factory; cache-enabled deletes select an exact ID set, delete that set on a cloned scoped builder, and invalidate it after settlement. | +| `database-26` | Pass the current connection name into commit and rollback callback registration so callbacks never attach to a newer transaction on another connection. | +| `database-27` | Preserve Laravel's ambient unnamed-callback locality and document that it follows the latest open transaction plus its enclosing stack on that connection, not every open connection. | +| `database-28` | Mark `Connection::unsetTransactionManager()` as tests-only because a pooled connection retains the null manager after release and breaks later after-commit scheduling. | +| `auth-18` | Settle cached Eloquent user invalidation on the mutated model's connection while preserving event-time cache-key inputs and commit-time descriptor discovery. | +| `sanctum-04`, `sanctum-13` | Restore current CSRF route controls and protected provider structure while retaining Hypervel's direct coroutine-safe guard and middleware-priority owner. | +| `sanctum-05`, `sanctum-10` | Use the request bearer parser and make `findToken()` the one strict `id\|token` authority, rejecting malformed and overflowing integer identifiers before cache or SQL. | +| `sanctum-06`, `sanctum-07` | Select the requested testing guard, preserve concrete authenticatable returns, normalize enums once, and compare stored abilities strictly. | +| `sanctum-08`, `sanctum-09` | Reset the host placeholder from one canonical default and retain the existing exact numeric cache configuration contracts. | +| `sanctum-12` | Reject invalid prune hours before issuing a destructive query and return standard command status codes. | +| `sanctum-14`–`sanctum-16` | Complete direct dependencies, provider discovery, public documentation, protected extension guidance, and truthful static-analysis types. | + +- **Architecture and worker ownership:** `SanctumGuard` remains a worker-shared direct guard with only the resolved user in coroutine context. Token caches, model-event invalidation, last-used throttling, and the token-specific relation remain owned by the configured token model and `HasApiTokens`; Auth retains its own user-cache descriptors. Database connections and `DatabaseTransactionsManager` remain the sole settlement owners. Static Sanctum configuration remains boot/test state reset by the existing subscriber. No request-local guard wrapper, transaction registry, cache generation, lock, retry, rollback repair, or new context slot was added. +- **Correctness and parity:** Model creation, update, soft delete, restore, force delete, and cache-enabled token-relation deletion now invalidate only after successful settlement. Internal last-used writes refresh only the token entry. Every cache path uses the custom model's real primary key and late-static namespace. Exact-set relation deletion preserves global and soft-delete scopes, excludes concurrently inserted tokens, and does not mutate a reused relation builder. Integer IDs fail before cache/SQL when malformed or outside the native integer range; string-key custom models remain supported. The current CSRF route, route controls, guard factory, bearer parser, test helper, enum abilities, prune validation, static reset, metadata, and public types are restored or corrected. +- **Transaction boundaries:** `database-26` is a deliberate fix for a defect also present upstream: named callbacks now belong to the registering connection. Unnamed manager calls retain Laravel's latest-open-transaction behavior. This does not provide cross-connection atomic coordination: work with multiple database dependencies must be scheduled after those dependencies commit or on the transaction that commits last. Early observability can be retryable but is not universally harmless; suppressing work after an unrelated rollback can be silent and unrecoverable, so no all-connections barrier was added. +- **Important rejected concerns and closed limitations:** Do not add model-wide builder interception, reflection into delete callbacks, hydrated deletion sets, ID ranges, cache tags, a rate limiter, string-key chunking without a supported limit case, raw-query listeners, or guarantees across raw SQL, quiet/eventless mutation, arbitrary builder updates, and bulk restore. The token model, token relation, and Auth provider retain the same small fail-closed branch rather than exposing an arbitrary-callback method on the user-extensible token model or adding a helper abstraction; Database is the actual shared owner, and all three independent settlement sites are counterfactually covered. The prune command deliberately leaves already-expired entries until TTL because they cannot authenticate; selecting IDs only to reclaim those entries would add unjustified maintenance I/O. +- **Cross-package revalidation:** `sanctum-01`, `sanctum-02`, `support-02`, and `cache-04` remain correct. Database owns `database-26` through `database-28`; Auth owns `auth-18`; Sanctum revalidates all four without a local transaction workaround. Bus, Foundation, Queue, Events, Mail, Notifications, Broadcasting, and Scout documentation now describes open parent transaction settlement rather than claiming coordination across every connection. Foundation also retains the explicit guard and middleware-priority owner, and Cache retains the secure model serialization policy. +- **Regression and static coverage:** Counterfactual coverage spans named commit/rollback callbacks across connections; immediate, nested, commit, rollback, and missing-manager settlement; negative-cache creation; general and last-used updates; instance and exact relation deletion; soft deletes; custom token connections, string keys, custom primary-key names and cache namespaces; reusable relation builders; strict identifier and ability inputs; current bearer parsing and CSRF routing; protected provider factories; exact route config types; command validation; metadata; static reset; and Auth's event-time/commit-time cache descriptors. Maximum-level types cover acting-as enums and concrete returns, configurable token models, trait-only relation construction, token creation, and current-token generics. +- **Performance and complexity:** Bearer authentication removes one model allocation and one duplicate validation pass. Ability checks avoid `array_flip()` allocation. Cache-disabled relation deletion remains one query; cache-enabled deletion adds one scalar ID-selection query only on that write path. Integer IDs use `whereIntegerInRaw()` without the ordinary bind ceiling; custom string keys retain the database's normal parameter limit. Transaction registration adds one local connection-name lookup only when scheduling a callback. Model/Auth mutations perform the same cache operations at the correct time. Routes, validation, metadata, reset, command, and documentation work is boot-, build-, test-, or maintenance-only. No read path gains a query, cache round trip, lock, yield, retry, container loop, serialization layer, or retained worker state. +- **Laravel-facing result:** Supported Sanctum names, signatures, named arguments, guard behavior, token model customization, and protected provider extension points remain compatible or are restored. Documented Hypervel differences remain the direct coroutine-safe guard, explicit per-guard `session_guards`, strict cached `id|token` format, header-only default credentials, Cache-aware token relation, and middleware-priority ownership. `newTokenRelation()` is a documented Hypervel extension that avoids hijacking the model-wide `newMorphMany()` hook. Malformed route options and destructive prune input now fail closed; valid calls are unchanged. +- **Validation and review:** Every changed focused Sanctum, Auth, and Database test and the affected maximum-level type fixture is green. The authoritative `composer fix` gate passed formatting, both PHPStan configurations, the full parallel suite, Testbench package mode, and dogfood. Post-gate review corrections passed the complete affected test files, both PHPStan configurations, targeted formatting, stale scans, and `git diff --check`. Independent review re-read the full diff, verified counterfactual coverage and rejected complexity, and signed off with no remaining finding. +- **Assessment:** Sanctum is coroutine-safe, transaction-settlement-correct, custom-model-safe, current at the audited Laravel surface, and bounded for long-lived workers. Every accepted finding is fixed at its lowest owner without a workaround, speculative abstraction, meaningful hot-path regression, unintended Laravel API break, unresolved defect, TODO, or stale superseded path. + ### Complete Translation correctness, current parity, and worker lifecycles - **Status and inspected surface:** Complete; implementation, focused validation, the authoritative gate, fresh caller/callee and hot-path self-review, and independent code review are signed off. The work corrected every verified prior-audit finding and the same-family issues found while tracing them across Translation, Contracts, Foundation, Validation, Auth, Routing, Session, View, Support facade metadata, package metadata, tests, and localization documentation. The detailed design is recorded in [`2026-08-06-0925-translation-correctness-current-parity-and-worker-lifecycles.md`](2026-08-06-0925-translation-correctness-current-parity-and-worker-lifecycles.md). | Findings | Final decision | |---|---| -| `translation-02` | Make the Translator contract truthful and complete with `array|string` retrieval, typed string/array accessors, fallback accessors, migrated string-only framework consumers, regenerated Lang metadata, and conditional `__()` metadata. | +| `translation-02` | Make the Translator contract truthful and complete with `array\|string` retrieval, typed string/array accessors, fallback accessors, migrated string-only framework consumers, regenerated Lang metadata, and conditional `__()` metadata. | | `routing-26`, `session-25`, `view-42` | Restore conditional helper metadata for Redirector/response, SessionManager/value/null, and View Factory/View results; remove the wrong Session Store-contract annotation. | | `translation-03`, `translation-04` | Use one paired-delimiter numeric selector grammar, preserve malformed conditions literally, reject accidental wildcard-only matches, and cast only modulo operands for PHP 8.4-safe pluralization. | | `translation-05` | Reject separator and exact dot-segment locales at both eager Translator assignment and the FileLoader trust boundary while preserving dot-bearing locale names. | diff --git a/docs/plans/2026-08-02-1321-http-correctness-json-api-and-current-laravel-parity.md b/docs/plans/2026-08-02-1321-http-correctness-json-api-and-current-laravel-parity.md index 021994134..1eb4e2ee6 100644 --- a/docs/plans/2026-08-02-1321-http-correctness-json-api-and-current-laravel-parity.md +++ b/docs/plans/2026-08-02-1321-http-correctness-json-api-and-current-laravel-parity.md @@ -81,7 +81,7 @@ The dynamic resolver must run before matching paths because the returned configu | `http-a06` | Make client request header lookup PSR-7 case-insensitive and literal, including dotted names; use current `array_all()` coverage. | | `http-a07` | Treat only trimmed empty request content as no JSON; decode valid `"0"`. | | `http-a08` | Port current Laravel cache-header handling for bodyless HEAD responses. | -| `http-a09` | Accept `Arrayable|array|JsonSerializable` payloads for body-bearing verbs and QUERY. | +| `http-a09` | Accept `Arrayable\|array\|JsonSerializable` payloads for body-bearing verbs and QUERY. | | `http-a10` | Correct the complete throw/throwIf/throwUnless family, callback storage, and status-code types. | | `http-a11` | Remove duplicate HTTP/Testbench configuration defaults at their consumers; retain partial-bootstrap defaults owned by `LoadConfiguration`. | | `http-a12` | Mark Factory global middleware configuration as boot-only. | diff --git a/docs/plans/2026-08-03-1909-mail-correctness-current-parity-and-package-boundaries.md b/docs/plans/2026-08-03-1909-mail-correctness-current-parity-and-package-boundaries.md index ec57fb6bb..3e61d5325 100644 --- a/docs/plans/2026-08-03-1909-mail-correctness-current-parity-and-package-boundaries.md +++ b/docs/plans/2026-08-03-1909-mail-correctness-current-parity-and-package-boundaries.md @@ -130,8 +130,8 @@ Keep current Laravel public APIs, named arguments, protected extension points, m | `support-29` | URL predicate parity defect | Minor | Port current single-label-domain support in `Str::isUrl()` for Mail, Stringable, and the framework `url` validation rule. | | `support-30` | Fake assertion type defect | Major | Make MailFake's shared assertion helper accept the count and address shapes its public methods support. | | `support-31` | Fake assertion type defect | Minor | Remove NotificationFake's non-callable string callback promise and port its complete current upstream unit suite. | -| `contracts-10` | Queue type consistency | Minor | Widen the Mail queue contract to the framework's `UnitEnum|string|null` identifier boundary. | -| `contracts-11` | Callback type consistency | Minor | Replace broad `mixed` callback parameters with Laravel's actual `Closure|string` boundary across the Mail contract, concrete implementation, fake, and facade. | +| `contracts-10` | Queue type consistency | Minor | Widen the Mail queue contract to the framework's `UnitEnum\|string\|null` identifier boundary. | +| `contracts-11` | Callback type consistency | Minor | Replace broad `mixed` callback parameters with Laravel's actual `Closure\|string` boundary across the Mail contract, concrete implementation, fake, and facade. | | `http-27` | Completed-package docs defect | Minor | Correct HTTP README ordering and add its canonical guide link. | | `filesystem-14` | Static-analysis type defect | Minor | Remove false `FilesystemAdapter` narrowing where supported pooled disks expose adapter methods outside the intentionally narrow contract. | diff --git a/docs/plans/2026-08-06-0916-view-correctness-lifecycle-and-current-parity.md b/docs/plans/2026-08-06-0916-view-correctness-lifecycle-and-current-parity.md index f9f5d03df..541575b8e 100644 --- a/docs/plans/2026-08-06-0916-view-correctness-lifecycle-and-current-parity.md +++ b/docs/plans/2026-08-06-0916-view-correctness-lifecycle-and-current-parity.md @@ -165,14 +165,14 @@ The inherited compiled Factory FQCN means overriding `parentPlaceholderSalt()` o | `view-18` | Document worker-lived mutators at their concrete boundaries. | | `view-19` | Record the approved alias-first component API as a Laravel difference. | | `view-20` | Document first-use-per-worker compiled-view freshness accurately. | -| `view-21` | Accept `BackedEnum|string` dynamic component names. | +| `view-21` | Accept `BackedEnum\|string` dynamic component names. | | `view-22` | Default unnamed slots to `slot`. | | `view-23` | Catch Xdebug-originated `ParseError` in `hasEvenNumberOfParentheses()`. | | `view-24` | Deduplicate nested `view:cache` roots without conflating path-prefix siblings. | | `view-25` | Port the current full-app View integration surface and fixtures. | | `view-26` | Hash inline-template cache keys to bound retained bytes. | | `view-27` | Replace, rather than append, the reserved `__components` namespace. | -| `view-28` | Narrow `stringable()` to its actually supported `Closure|string` domain; route the Translation twin separately. | +| `view-28` | Narrow `stringable()` to its actually supported `Closure\|string` domain; route the Translation twin separately. | | `view-29` | Make boot echo format worker-lived and callback overrides coroutine-local and nest-safe. | | `view-30` | Memoize only verified-fresh compiled paths, restoring `view.cache=false` and first-render deletion recovery. | | `view-31` | Delete the superseded footer property and type/relocate `pushFooter()`. | diff --git a/docs/plans/2026-08-06-0925-translation-correctness-current-parity-and-worker-lifecycles.md b/docs/plans/2026-08-06-0925-translation-correctness-current-parity-and-worker-lifecycles.md index 0b3b63fdc..80360e233 100644 --- a/docs/plans/2026-08-06-0925-translation-correctness-current-parity-and-worker-lifecycles.md +++ b/docs/plans/2026-08-06-0925-translation-correctness-current-parity-and-worker-lifecycles.md @@ -154,7 +154,7 @@ Preserve these in the owner handoff without changing the cited references or fix | `translation-11` | Type / upstream metadata parity | Minor | Complete loader map shapes and the fallback type. | | `translation-12` | Provider / package ownership cleanup | Minor | Use typed `make()` resolution, one config resolution, no duplicate defaults or promoted assignment, and remove the unused direct Container dependency. | | `translation-13` | Test ownership and isolation | Minor | Replace process-global fixture communication, restore inherited coroutine execution, correct bases/types, and remove stale fixture code. | -| `translation-14` | Array translation defect | Major | Make the replacement boundary array-capable so JSON and PHP-file arrays preserve mixed leaves, explicitly keyed empty arrays remain values, and `get(): array|string` is honored consistently. | +| `translation-14` | Array translation defect | Major | Make the replacement boundary array-capable so JSON and PHP-file arrays preserve mixed leaves, explicitly keyed empty arrays remain values, and `get(): array\|string` is honored consistently. | ## Approval surface before implementation diff --git a/docs/plans/2026-08-07-1302-sanctum-correctness-cache-settlement-and-current-parity.md b/docs/plans/2026-08-07-1302-sanctum-correctness-cache-settlement-and-current-parity.md new file mode 100644 index 000000000..6a833db72 --- /dev/null +++ b/docs/plans/2026-08-07-1302-sanctum-correctness-cache-settlement-and-current-parity.md @@ -0,0 +1,658 @@ +# Sanctum correctness, cache settlement, and current parity + +## Objective + +Complete the Sanctum audit by fixing verified authentication, revocation, transaction, +configuration, routing, command, type, metadata, and documentation defects at their lowest owners. +Preserve Hypervel's coroutine-safe guard, explicit session-guard model, token cache, strict +`id|token` format, provider validation, sticky-read optimization, and configurable token model. + +Public Laravel APIs remain compatible unless an existing behavior is itself unsafe or incorrect. +The only new protected Sanctum extension point is the token-specific relation factory required to +make documented bulk revocation cache-correct without intercepting unrelated Eloquent relations. + +## Evidence baseline + +- Hypervel branch baseline: `0.4` at `128c71b73`. +- Audited Hypervel snapshot: `db70c7ce7def14382d7d22d2f90b15e8db0ae9d7`. +- Current Laravel Sanctum reference: `7fb0d860302f9dd45c8d0de363d860e1666a5771`. +- Current Laravel framework reference: `9f27fa054af628015e7ada84b0571e7b86cea03e`. +- Historical Sanctum changes inspected for intent and complete file scope: + `9526c2c` (optional last-used tracking), `56d32449` (strict abilities), `6cf798f` + (route switch), `3f44e31` (provider extension structure), `fe361b9` (token generic), + and `3f57a4c` plus revert `d61eb74` (integer token IDs). +- The complete local Sanctum source, tests, split/root metadata, Boost guide, Database + transaction manager, Auth model cache, Foundation auth configuration, and middleware + configuration were traced. Focused probes reproduced every accepted defect. +- `sanctum-01`, `sanctum-02`, and `cache-04` remain complete. The old audit label + `sanctum-02` for relation deletion collided with the completed JSON finding; the relation + correction is `sanctum-17`. The custom-model cache seam correction is `sanctum-18`. + New shared-owner findings are `database-26`, `database-27`, `database-28`, and + `auth-18`. + +## Anti-overengineering rules + +The following wording is retained verbatim from the core audit plan. Its principle numbering is +also retained; principles 1–6 remain in the core operating plan. In principle 9, “later in this +plan” refers to that plan's **Established remediation vocabulary** section. + +This audit is not permission to add defensive machinery for every imaginable failure. Do not add an abstraction, state machine, retry loop, configurable timeout, registry, mutex, context slot, cache, or compatibility API merely because it sounds robust. + +Complexity must pay for itself with at least one of: + +- a demonstrated failure; +- a complete source trace proving a realistic vulnerable schedule; +- a clear general capability with real consumers and owner approval; +- deletion of greater or riskier complexity elsewhere. + +Typical Laravel lifecycle semantics define the supported contract. A package that intentionally relies on model events, middleware, listeners, transactions, or another documented mechanism is not defective merely because userland can explicitly bypass that mechanism. Do not build a parallel enforcement path for `withoutEvents()`, raw database writes, disabled middleware, direct transport access, or comparable deliberate bypasses unless the public contract explicitly promises behavior through that bypass. + +Underengineering is equally a failure. Fix every verified defect completely at its lowest owning boundary, never with a partial fix or a local patch over a broken shared contract, and always surface meaningful evidence-backed improvements rather than dropping them to avoid effort. Restraint applies to speculative machinery and cosmetic change, not to complete fixes or worthwhile opportunities. + +Do not treat an upstream difference as a bug without tracing it. Do not treat upstream parity as proof of correctness. A real Hypervel defect remains a defect when Laravel, Hyperf, Symfony, or an SDK has the same hole. + +The audit categories are discovery lenses, not boundaries around what may be corrected. Any genuine issue discovered while auditing, implementing, testing, or reviewing must be investigated, assigned to its lowest owning boundary, and taken through the applicable consensus, implementation, validation, review, and approval workflow—even when it is outside the current package, initial taxonomy, or changed diff. Do not dismiss a verified issue as unrelated or defer it merely to preserve package order. This rule applies only after the evidence threshold is met; it does not turn speculative concerns, deliberate bypasses, unsupported use, or contract violations into work. + +### 7. Preserve hot-path quality + +For every fix, inspect: + +- additional allocations; +- container or facade resolutions; +- locking and atomics; +- hashing and serialization; +- new yields or sleeps; +- retries and polling; +- logging or exception construction; +- retained worker memory; +- cache invalidation and eviction. + +A correctness guard on a cold failure path has a different cost from a new lock or resolver on every request. State the difference explicitly. + +Any proposed change with a measured or source-proven hot-path regression requires explicit owner approval before implementation, even when it fixes a defect. Present the expected frequency and magnitude, the evidence, and the viable alternatives. Do not hide an unavoidable tradeoff inside a general correctness claim. + +Performance improvements must provide a meaningful practical benefit after accounting for code complexity and divergence from upstream. Measure representative behavior where practical. Always surface an evidence-backed opportunity to the owner, but do not implement it without approval; a micro-optimization within measurement noise is neither a reason to diverge nor an actionable finding. + +### 8. Remove superseded design completely + +When a fix changes the owning model, delete obsolete helpers, callbacks, properties, config keys, comments, tests, and documentation. Do not leave a compatibility path or comment describing behavior that no longer exists. Preserve intentional upstream comments unless the new design makes them incorrect. + +### 9. Treat remediation patterns as candidates + +The established patterns later in this plan are a vocabulary, not a lookup table. Choose among per-call parameters, immutable values, scoped bindings, cloning, CoroutineContext, factories, explicit ownership, static reset, or resource teardown only after proving the real lifetime and owner. + +### 10. Reject speculative complexity + +Record low-confidence concerns under rejected or unresolved analysis. Do not implement them. Surface every evidence-backed, meaningful non-defect improvement to the owner with its benefit, cost, and alternatives, then stop for explicit approval. This requirement exists to keep worthwhile opportunities visible, not to discourage finding them. + +## Architecture and retained boundaries + +- `SanctumGuard` remains a worker-shared direct guard whose resolved user is stored only in + `CoroutineContext`. Do not port Laravel's mutable `RequestGuard` or request rebinding. +- `PersonalAccessToken` owns token/tokenable cache keys, lookup, model-event invalidation, + last-used throttling, and cache refill ordering. +- `HasApiTokens` owns the one package-provided token relationship. A Sanctum relation subtype may + change only this relation's cache-enabled delete path. +- Database connections and `DatabaseTransactionsManager` remain the sole transaction-settlement + owners. Sanctum and Auth schedule callbacks; neither adds transaction state. +- Unnamed manager callbacks retain Laravel's ambient locality: they follow the most recently + started open transaction and its enclosing stack on that connection. They do not coordinate + transactions across connections. +- `Sanctum` owns boot/test worker-static token-model and callback configuration. The existing + global test subscriber remains the reset owner. +- Foundation configuration owns the shipped `sanctum` guard and required `session_guards`. + `Middleware::statefulApi()` owns middleware priority. +- Cache remains disabled by default. Cache-disabled relation deletion keeps its existing + one-query path; authentication does not gain a query, lock, generation, or cache round trip. + +## Findings and final decisions + +| ID | Category | Severity | Final decision | +|---|---|---:|---| +| `sanctum-17` | Relation revocation cache defect | Major | Use a token-specific `MorphMany` subtype and protected factory; select exact IDs only when caching is enabled, delete exactly that set, and invalidate after successful settlement. | +| `sanctum-03` | Token cache settlement defect | Major | Move automatic invalidation to successful model events and settle it through the model connection. Clear pre-created negative entries on creation. | +| `sanctum-18` | Custom token-model cache defect | Major | Use late static binding consistently for every protected cache seam so custom key/store overrides are read, written, and invalidated coherently. | +| `database-26` | Cross-connection callback defect and upstream defect | Major | Pass the connection name from `Connection::afterCommit()` and `afterRollBack()` to the existing transaction manager. | +| `database-27` | Ambient callback contract documentation | Moderate | Preserve Laravel's latest-open-transaction behavior and correct claims that it coordinates every open connection. | +| `database-28` | Pooled-connection lifecycle warning | Minor | Mark `Connection::unsetTransactionManager()` tests-only because the null manager survives pool release and breaks later callback scheduling. | +| `auth-18` | User-cache settlement defect | Major | Settle Eloquent user cache invalidation on the model connection while preserving event-time identifier resolution and commit-time descriptor discovery. | +| `sanctum-04`, `sanctum-13` | CSRF route and provider parity | Major | Port current route and protected provider structure, adapted to Hypervel's direct guard and middleware owner. | +| `sanctum-05`, `sanctum-10` | Credential transport and token-ID validation | Major | Use the request bearer parser, centralize validation in `findToken()`, reject empty halves and out-of-range integer IDs before cache/SQL, and preserve custom overrides/string keys. | +| `sanctum-06`, `sanctum-07` | Testing helper and ability correctness | Moderate | Select the requested guard, normalize enums once, and compare abilities strictly without `array_flip()`; retain the already-exact trait check. | +| `sanctum-08` | Static cleanup | Minor | Restore the host placeholder from one canonical constant. | +| `sanctum-09`, `sanctum-11` | Config and JSON contracts | Major/Moderate | Revalidate the already-complete numeric config and single-pass throwing JSON corrections; do not rewrite them. | +| `sanctum-12` | Destructive command validation and upstream defect | Major | Reject negative, decimal, and nonnumeric hours before issuing a query; preserve zero. | +| `sanctum-14` | Package metadata | Moderate | Declare all direct split dependencies and mirror provider discovery in the root package. | +| `sanctum-15` | Public documentation | Minor | State exact automatic invalidation, transaction timing, deliberate escape hatches, bounded-cache guidance, and concise public differences. | +| `sanctum-16` | Public/static-analysis types | Minor | Type real runtime contracts, preserve concrete `actingAs()` returns, and use only proof-local static-analysis suppression where PHP cannot express trait capability. | + +## Implementation + +### 1. Correct named transaction callback ownership and manager-removal guidance (`database-26`, `database-28`) + +In `ManagesTransactions`, forward the current connection name to the manager: + +```php +$this->transactionsManager->addCallback($callback, $this->getName()); +$this->transactionsManager->addCallbackForRollback($callback, $this->getName()); +``` + +The existing public signatures, immediate behavior, FIFO callback queues, nested transaction +records, and manager API remain unchanged. This fixes Laravel's current cross-connection bug: +without the name, `latestApplicableTransaction(null)` may attach a callback to the newest record +from another connection. + +State in both connection method docblocks that callbacks belong to that connection. Unnamed +manager callbacks deliberately remain ambient and select the latest applicable record across +connections. This supports independent transactions and matches Laravel. Do not add an +all-connections barrier: the manager cannot infer a callback's dependency set, and suppressing a +callback because an unrelated transaction rolled back is silent and unrecoverable. An early job is +often observable and retryable, but not universally; neither behavior supplies atomic commits +across connections. + +Mark `Connection::unsetTransactionManager()` tests-only without changing its signature, +visibility, or behavior. A pooled connection retains the null manager after release, so calling +this method at runtime breaks after-commit scheduling for later borrowers. + +Sanctum and Auth use this two-branch settlement shape: + +```php +$connection = $model->getConnection(); + +if ($connection->getTransactionManager() === null && $connection->transactionLevel() === 0) { + $callback(); + + return; +} + +$connection->afterCommit($callback); +``` + +With a manager, Database decides whether to queue or run immediately. Without a manager and an +open transaction, the existing `RuntimeException` is retained so cache correctness fails closed. +Do not catch it, add rollback repair, or build a package transaction abstraction. + +### 2. Make relation revocation cache-correct (`sanctum-17`) + +Add `PersonalAccessTokenRelation extends MorphMany` and have `HasApiTokens::tokens()` construct it +through a dedicated protected `newTokenRelation()` factory. This deliberately replaces the +incidental use of the model-wide `newMorphMany()` hook for this package-owned relationship. Add a +short source comment explaining the distinction and prove that applications can override the new +factory. + +Declare `tokens(): PersonalAccessTokenRelation` natively with +`@return PersonalAccessTokenRelation<$this>`. Define the relation as +`@template TDeclaringModel of Model` extending +`MorphMany`. The trait's independent `TToken` template +continues to describe the current access token, including `TransientToken`; it no longer falsely +parameterizes the concrete database relation. + +The factory duplicates only Eloquent's small morph-relation construction boundary: + +```php +protected function newTokenRelation(): PersonalAccessTokenRelation +{ + $instance = $this->newRelatedInstance(Sanctum::personalAccessTokenModel()); + [$type, $id] = $this->getMorphs('tokenable', null, null); + + return new PersonalAccessTokenRelation( + $instance->newQuery(), + $this, + $instance->qualifyColumn($type), + $instance->qualifyColumn($id), + $this->getKeyName(), + ); +} +``` + +The relation's `delete(): mixed` remains one query when cache is disabled, matching +`Eloquent\Builder::delete()` without narrowing custom `onDelete` results. Since relation deletion +normally reaches the builder through `Relation::__call()`, the disabled branch delegates exactly +to `$this->getQuery()->delete()`; there is no parent relation `delete()` method. When enabled: + +```php +$ids = (clone $this->getQuery())->pluck($this->getRelated()->getQualifiedKeyName()); + +if ($ids->isEmpty()) { + return 0; +} + +$deleted = (clone $this->getQuery())->whereKey($ids->all())->delete(); + +$this->settleInvalidation($ids->all()); + +return $deleted; +``` + +Use cloned Eloquent builders for both selection and deletion so global and soft-delete scopes +apply without retaining the internal exact-ID constraint on a reused relation. Call Eloquent +Builder's existing delete so `SoftDeletes` keeps its `onDelete` behavior. Capture only scalar IDs; +do not hydrate models. + +Queue invalidation for every selected ID after a successful delete, regardless of delete count. +This exact-set rule prevents tokens inserted between selection and deletion from being removed +without invalidation. Integer keys use Eloquent's `whereIntegerInRaw()` and avoid bind-parameter +ceilings. Custom string keys keep normal `whereIn()` behavior; do not add chunking or range logic. +Settle the callback through `$this->getRelated()->getConnection()`, never the tokenable parent's +connection; the token model connection owns both the delete and its transaction record. In the +callback, invalidate each ID through `$this->getRelated()::clearTokenCache($id)` so the configured +custom token model's late-static cache namespace remains authoritative. + +### 3. Settle token model cache mutations after success (`sanctum-03`) + +Before changing listener timing, replace every cache-helper call in `PersonalAccessToken` from +`self::` to `static::`, including reads, tokenable reads, invalidation, and nested key construction. +`updateLastUsedAt()` already uses `static::`; the current mixture makes a documented custom token +model's protected `getCache()`/`getCacheKey()` overrides write under one namespace while reads and +invalidations use another. This correction is `sanctum-18` and adds no runtime work. + +```php +$cache = static::getCache(); +$cacheKey = static::getCacheKey($id); + +static::forgetTokenEntry($cache, $id); +static::clearTokenCache($id); +``` + +Replace `updating`/`deleting` listeners with `created`, `updated`, and `deleted` listeners: + +```php +static::created(function (self $token): void { + $id = $token->getKey(); + + $token->settleCacheMutation( + fn () => static::forgetTokenEntry(static::getCache(), $id) + ); +}); + +static::updated(function (self $token): void { + $id = $token->getKey(); + $lastUsedAtOnly = $token->wasOnlyLastUsedAtChanged(); + + $token->settleCacheMutation(fn () => $lastUsedAtOnly + ? static::forgetTokenEntry(static::getCache(), $id) + : static::clearTokenCache($id)); +}); + +static::deleted(function (self $token): void { + $id = $token->getKey(); + + $token->settleCacheMutation(fn () => static::clearTokenCache($id)); +}); +``` + +The listeners return before this shape when caching is disabled. The helper names are illustrative; +the final compact protected helpers must retain the two-branch settlement semantics from step 1 +and snapshot every scalar decision before deferral. Creation clears only the token entry, closing +the security-relevant pre-created negative-cache window. + +For successful updates, inspect `getChanges()` after save and remove the actual timestamp column +only when `getUpdatedAtColumn()` is non-null. A remaining exact `['last_used_at']` change is the +internal audit write and clears only the token entry. General changes and every instance delete +clear token and tokenable entries. Keep the extracted `wasOnlyLastUsedAtChanged()` helper protected; +tests assert its observable invalidation result. Cancelled events schedule nothing. + +After `updateLastUsedAt()` saves, queue a relation-free token snapshot put after the updated-event +forget. The existing `withoutRelation('tokenable')` clone, FIFO callback ordering, write-sticky +state restoration, throttling, and failed-save restoration remain intact. + +### 4. Settle Auth's Eloquent user cache (`auth-18`) + +Retain one model listener pair in `EloquentUserProvider`, but replace immediate forget with the +same connection-owned settlement rule. Resolve the optional cache-key callback's identifier segment +at the model event, because the model and request-scoped resolver inputs may change before commit. +Read the descriptor registry inside the committed callback so providers registered while the +transaction is open are also invalidated. + +Do not share a new helper across Auth and Sanctum: their payloads and lifecycles differ, while the +Database connection already supplies the shared primitive. + +### 5. Restore the usable CSRF route and provider extension structure + +Delete `src/sanctum/routes/web.php`. Add current upstream-shaped protected methods in this order: + +```php +public function boot(): void +{ + // Existing cache policy, validation, publishing, and command ownership. + $this->defineRoutes(); + $this->configureGuard(); + $this->configureMiddleware(); +} + +protected function defineRoutes(): void +{ + if ($this->app->routesAreCached()) { + return; + } + + $config = $this->app->make(ConfigRepository::class); + + if (! $config->boolean('sanctum.routes', true)) { + return; + } + + Route::group(['prefix' => $config->string('sanctum.prefix', 'sanctum')], function (): void { + Route::get('/csrf-cookie', [CsrfCookieController::class, 'show']) + ->middleware('web') + ->name('sanctum.csrf-cookie'); + }); +} +``` + +`configureGuard()` and `createGuard()` preserve the direct coroutine-safe `SanctumGuard`, guard +name, configured provider, required `session_guards`, optional event dispatcher, expiration, and +last-used setting. Do not port request refresh/rebinding. + +`configureMiddleware()` retains the existing frontend cookie hardening. It does not mutate kernel +priority: `Middleware::statefulApi()` owns that in Hypervel. Record this deliberate same-named API +difference in the README, a concise source comment, and a `REMOVED:` marker at the matching +upstream test boundary. + +The typed route getters make malformed `sanctum.routes` and `sanctum.prefix` values fail during +provider boot rather than guessing. Document the boolean/string contracts beside the public CSRF +route controls. + +Do not merge a Laravel default `auth.guards.sanctum` in the provider. Foundation already publishes +the explicit guard with `session_guards => ['web']`; a package provider cannot invent that required +application choice. Clarify the existing omission marker/difference instead. + +In `EnsureFrontendRequestsAreStateful::frontendMiddleware()`, retain the supported +class-string-or-null contract with `$candidate !== null && $candidate !== ''` and strict +deduplication. Do not silently discard invalid non-string configuration; let the middleware +pipeline report it. Keep the missing `authenticate_session` default as the intentional optional +case. + +```php +if ($candidate !== null + && $candidate !== '' + && ! in_array($candidate, $filtered, true)) { + $filtered[] = $candidate; +} +``` + +### 6. Centralize bearer parsing and token lookup validity + +`SanctumGuard::getTokenFromRequest()` preserves the configured retrieval callback and otherwise +calls `Request::bearerToken()`. Delete `getBearerToken()`, `isValidBearerToken()`, input/query token +fallback, and the duplicate configured-model allocation. + +Default `PersonalAccessToken::findToken()` remains authoritative for the supported format: + +```php +if (! str_contains($token, '|')) { + // Hypervel only supports the id|token format created by createToken(). + // Laravel's legacy plain-token lookup is intentionally omitted because + // Sanctum's cache and invalidation paths are keyed by token ID. + return null; +} + +[$id, $plainToken] = explode('|', $token, 2); + +if ($id === '' || $plainToken === '') { + return null; +} + +if ((new static)->getKeyType() === 'int' + && (! ctype_digit($id) || filter_var($id, FILTER_VALIDATE_INT) === false)) { + return null; +} +``` + +No cache or query runs for invalid identifiers. Do not cast the ID: overflow must not alias a valid +row. Custom string-key models retain nonnumeric IDs, and a custom model's `findToken()` override +remains fully authoritative. + +Delete the one-caller protected `isValidTokenIdentifier()` helper rather than retain a second +format owner. `findToken()` is the documented override point; removing the Hypervel-only helper is +a deliberate cleanup, not a Laravel API change. + +Port current request-parser assertions for case-insensitive schemes, last `Bearer ` occurrence, +comma truncation, and absence. Add Hypervel assertions for headers, rejected query/body/array input, +empty halves, integer overflow, in-range values, callback transport, string keys, and custom lookup. + +### 7. Correct abilities and the testing helper + +`PersonalAccessToken::can()` normalizes the requested `UnitEnum|string` once and uses strict linear +membership for wildcard and exact values. Remove `array_flip()`: + +```php +$ability = enum_value($ability); + +return in_array('*', $this->abilities, true) + || in_array($ability, $this->abilities, true); +``` + +`Sanctum::supportsTokens()` already uses an exact trait-map lookup; retain and revalidate it. +`TransientToken` remains unconditional. + +`Sanctum::actingAs()` accepts `Authenticatable`, templates and returns the same concrete user, +normalizes both supplied and requested abilities with `enum_value()`, treats wildcard/exact matches +strictly, returns false for unlisted abilities, sets the requested guard's user, and calls +`shouldUse($guard)`. + +Preserve the existing `wasRecentlyCreated` behavior for Eloquent and compatible custom +authenticatables. Since that property is not on the `Authenticatable` contract, use narrowly scoped +`property.notFound` suppressions with reasons at the read and assignment; do not add a runtime +`instanceof Model` branch solely for PHPStan. + +Its docblock must begin with `Tests only.` and state that it installs a Mockery token double and +replaces the current coroutine's authenticated user/default guard with test state. Mockery remains +a development dependency and this is not an application API. + +### 8. Correct static state and type contracts + +Define one protected host-placeholder default constant, initialize the property from it, and restore +it in `flushState()`. + +Remove the unhelpful generic class template from `Sanctum`. Type its model property, accessor, and +mutator as `class-string`. Keep `HasApiTokens`' token generic bounded by +`HasAbilities`, because `TransientToken` is valid. + +After `supportsTokens()` proves trait use, keep only identifier-scoped `method.notFound` +suppressions with a reason at the exact `withAccessToken()` calls. Do not add runtime interfaces or +guards for PHPStan. Add `types/Sanctum/Sanctum.php` for acting-as enums, concrete returns, token-model +configuration, and documented trait-only models. The trait-only fixture must call both `tokens()` +and `createToken()` so PHPStan analyzes the otherwise zero-production-use trait and its protected +relation construction. + +### 9. Revalidate config and JSON; correct pruning + +- Retain the existing integer cast for `sanctum.cache.ttl` and the existing + `FILTER_VALIDATE_INT` plus null-on-failure form for `last_used_at_update_interval`; startup + validation owns malformed values and zero is valid. +- Retain `NewAccessToken::toJson()`'s existing single `json_encode()` with caller flags ORed with + `JSON_THROW_ON_ERROR` and its `JsonException` declaration. +- Resolve the configured token model through `Sanctum::personalAccessTokenModel()`. Validate + `--hours` as an integer with minimum zero before constructing the prune query. Invalid negative, + decimal, and nonnumeric values print a concise named error and return `Command::FAILURE`; zero + and positive integers proceed and return `Command::SUCCESS`. + +```php +$hours = filter_var( + $this->option('hours'), + FILTER_VALIDATE_INT, + ['options' => ['min_range' => 0]], +); + +if ($hours === false) { + $this->error('The --hours option must be a non-negative integer.'); + + return Command::FAILURE; +} +``` + +No config validation subsystem or second JSON pass is added. + +### 10. Complete metadata, public docs, and provenance + +In the split manifest add direct runtime dependencies: `ext-ctype`, `ext-filter`, `ext-json`, +`hypervel/cookie`, `hypervel/foundation`, `hypervel/session`, and +`symfony/http-foundation`. `symfony/console` already exists. Add Sanctum's provider to root package +discovery, not `DefaultProviders`. Add executable root/split metadata coverage. + +Order `src/sanctum/README.md` as header, official Documentation link, Differences From Laravel, +then Ported from. Keep the difference list concise and public: + +- token-specific relation factory instead of `newMorphMany()`; +- explicit default guard, removal of Laravel's global `sanctum.guard` accept-list, required + per-guard `session_guards`, and provider-checked stateful users; +- middleware priority owned by `Middleware::statefulApi()`; +- the currently false claim that every deletion invalidates immediately, replaced with the exact + instance/relation paths and after-commit timing; +- corrected strict-format and other existing cache differences. + +Update the Boost guide to state that instance create/update/delete and cache-enabled token relation +deletes invalidate automatically after successful commit; last-used writes refresh only the token +entry; raw SQL, quiet/eventless mutation, arbitrary builder updates, and bulk restore require +explicit `clearTokenCache()` handling on the configured token model class. Document exact-set +relation deletion without SQL detail, and explain that the prune command's already-expired entries +remain harmlessly cached until TTL. +Advise bounded cache capacity/eviction and normal application rate limiting for attacker-controlled +negative-cache cardinality. Do not imply Hypervel supplies a rate limiter here. + +Correct inherited “all open database transactions” wording in the Bus, Foundation, Queue, Mail, +Notifications, Events, Broadcasting, and Scout documentation. Concise source summaries say “open +parent database transactions”; the manager docblocks and Queue guide own the precise rule: +after-commit work uses the most recently started open transaction and runs after its enclosing stack +on that connection commits. Work depending on other connections must be dispatched only when those +dependencies are already committed or the selected transaction will commit last. + +### 11. Complete audit records + +Update the core plan's dependency index and checklist, plus the completion ledger: + +- add final dependency-index rows for `sanctum-03` through `sanctum-10` and `sanctum-12` + through `sanctum-18`, plus `database-26` through `database-28` and `auth-18`; `sanctum-11` is superseded by the + completed `sanctum-02` JSON finding and must not become a duplicate row; +- route `database-26` to Database, Sanctum, and Auth and mark all revalidation complete; +- route `database-27` to Database, Bus, Foundation, Queue, Events, Mail, Notifications, + Broadcasting, and Scout; +- route `database-28` to Database, Auth, and Sanctum; +- route `auth-18` to Auth and Sanctum and mark both complete; +- edit the existing `support-02`, `sanctum-01`, and `cache-04` dependency-index rows to remove + their pending/later-full-Sanctum wording and mark Sanctum revalidated; retain the completed + `sanctum-02` JSON row and remove its later-full-Sanctum wording; +- add a completed Sanctum entry with final findings, architecture, compatibility, performance, + rejected concerns, validation, and this detail-plan link; +- change the core package checklist entry for `sanctum` to complete. + +The ledger must state that exact-ID relation selection has no normal integer-key bind ceiling, +while custom string-key models retain the database's ordinary parameter limit. Record +`database-26` as a deliberate upstream correctness fix and the dedicated relation factory as a +documented Hypervel extension difference. Record `database-27` as Laravel-identical ambient +locality, not cross-connection atomic coordination, including the qualified observability tradeoff. + +## Test plan + +### Database + +- Default transaction with a newer second-connection transaction: a callback registered on default + fires only after default commits. +- Same ordering with default rollback: the callback never fires after the other connection commits. +- Register a rollback callback on a connection that is not the newest open transaction and prove it + follows only that connection's rollback. +- Callback on a connection with no application transaction while another connection has one runs + immediately. +- Rollback callback on a connection with no application transaction is not attached to another + connection's transaction. +- Preserve same-connection, suffixed-connection, nested, commit, and rollback coverage. Add the + currently absent connection-without-manager exception coverage required by fail-closed consumers. +- Retain the existing manager tests that counterfactually pin unnamed latest-transaction locality; + no barrier or duplicate ambient test is added. + +### Sanctum cache and relations + +- Pre-poison a future ID, create that exact token, and authenticate immediately. +- Successful, cancelled, no-transaction, outer/nested commit, and rollback create/update/delete. +- Cache refill before commit cannot survive committed mutation; rollback preserves committed state. +- Last-used FIFO forget/put, timestamp-disabled model, custom timestamp column, failed save, + throttling, relation-free snapshots, and sticky-read restoration. +- Instance soft delete, restore, and force delete through a custom token model. +- Cache-enabled all-token, constrained, zero-match, soft-delete, transaction, token-model-connection, + integer-key, and custom string-key relation deletion; cache-disabled delete retains one query. +- Managerless relation deletion inside an open transaction fails closed and preserves both cache + entries after rollback. +- Override `newTokenRelation()` and prove it remains the supported construction seam. +- Override a custom token model's primary-key name and protected `getCacheKey()`, then warm, mutate, + and prove reads, refill, instance invalidation, and relation-delete invalidation all use its real + primary key and subclass namespace. +- Reuse a relation after cache-enabled deletion and prove the internal exact-ID constraint was not + retained on its builder. + +### Sanctum HTTP, provider, command, and types + +- Real default CSRF response has a session and expected cookies; assert route name, exact `web` + middleware, default/custom prefix, disabled routes, cached routes, and malformed route option + types. +- Exercise protected `defineRoutes()`, `configureGuard()`, `createGuard()`, and + `configureMiddleware()` through provider test subclasses. +- Bearer parsing and invalid/default/custom lookup cases from step 6. +- Acting-as guard selection, enums, wildcard, unlisted ability, concrete return, and current-token + template type. +- Strict stored ability comparison, exact existing trait detection, static host reset, retained + numeric config types, retained throwing JSON, strict optional middleware filtering, and prune + invalid/zero/positive cases with no-query proof for invalid input. +- Split/root metadata and provider discovery. +- Maximum-level `types/Sanctum/Sanctum.php`; the existing full type gate covers Auth and Database. + +### Auth + +- Saved/deleted user invalidation outside transactions, after commit, and discarded on rollback. +- No-manager/no-transaction immediate settlement and no-manager/open-transaction failure. +- Cross-connection callback ownership. +- Event-time key-resolver segment with commit-time descriptor discovery. + +### Verification cadence + +Use focused tests while editing. After implementation, run `composer fix` once as the authoritative +gate; it runs formatting, PHPStan, and the complete parallel suite. Then run `git diff --check`, +stale-reference scans, and a fresh caller/callee, lifecycle, performance, compatibility, and +overengineering review before external code review. + +## Performance and compatibility budget + +- Bearer authentication becomes cheaper: one model allocation and one duplicate validation pass + are removed. Integer validation is two local scalar checks before cache/SQL. +- Strict ability scans avoid `array_flip()` allocation and operate on normally tiny lists. +- Token/Auth mutation paths perform the same cache invalidations at the correct settlement point; + callback registration is local and non-yielding. +- Late static binding replaces early static binding at existing cache-helper calls; it adds no + operation and makes the documented custom-model seam coherent. +- Cache-enabled token relation deletion adds one ID-selection query. Cache-disabled deletion keeps + one query. No read path changes. +- `database-26` adds one connection-name argument lookup to callback registration only, not queries + or request reads. +- `database-27` changes documentation only; unnamed Queue/Event callback selection remains O(1). +- `database-28` changes documentation and tests only. +- Routes, config casts, provider structure, metadata, static cleanup, types, and docs are boot, + command, test, or build-time only. +- Laravel public names/signatures are restored or preserved. Intentional differences are limited to + the existing Hypervel guard/cache/session architecture, the dedicated token relation factory, + strict `id|token` support, removal of undocumented request-input credentials, fail-closed + destructive input, and the named-connection upstream bug fix. + +## Rejected concerns + +- No generic Eloquent builder hook, model-wide relation override, reflection into delete callbacks, + or unbounded model hydration. +- No cache generation, lock, tag, retry, registry, rate limiter, ID range, rollback repair, or raw + query listener. +- No guarantees across raw SQL, quiet/eventless mutation, arbitrary builder updates, or bulk restore. +- No duplicated transaction owner or shared Auth/Sanctum settlement abstraction. +- No all-connections callback barrier or new Queue/Event dependency protocol; applications must + choose a dispatch point consistent with their cross-connection dependencies. +- No Laravel `RequestGuard`, request rebinding, mutable middleware priority, or provider-invented + `session_guards`. +- No cast of integer token IDs, legacy plain-token lookup, or compatibility transport via query/body. +- No runtime interfaces or guards solely for static analysis. +- No universal string-key deletion chunking without a demonstrated supported consumer and limit. +- No cache for `currentApplicationUrlWithPort()` or config-validation framework for two values. + +## Completion criteria + +- Every accepted Sanctum, Database, and Auth finding is implemented at its lowest owner. +- All superseded helpers, route files, stale comments, false docs, and broad suppressions are removed. +- Counterfactual tests cover every corrected behavior and intentional omission. +- `composer fix`, diff checks, stale scans, self-review, and independent code review are green. +- The core routing index, dependency index, ledger, and checklist truthfully record completion. diff --git a/src/auth/src/EloquentUserProvider.php b/src/auth/src/EloquentUserProvider.php index dac79a4c3..b439937eb 100755 --- a/src/auth/src/EloquentUserProvider.php +++ b/src/auth/src/EloquentUserProvider.php @@ -475,11 +475,11 @@ protected function effectiveCacheTags(): array * * Uses a descriptor-based registry: each (storeName, prefix) pair is * stored under a deterministic hash for its model class so duplicate - * configs collapse. On save/delete, the listener iterates descriptors - * for the model class, re-resolves each store by name via the cache - * manager, rebuilds the key using the current global resolver callback, - * and calls forget(). Nothing holds a reference to a provider instance — - * safe against forgetGuards() + re-resolve cycles under Swoole. + * configs collapse. On save/delete, the listener resolves the cache-key + * identifier while the model context is available. After commit, it reads + * the current descriptors, re-resolves each store by name, and calls + * forget(). Nothing holds a reference to a provider instance — safe + * against forgetGuards() + re-resolve cycles under Swoole. * * Event listener registration is guarded by the model's dispatcher * being non-null — HasEvents::registerModelEvent() silently no-ops @@ -518,14 +518,25 @@ protected function registerCacheInvalidationEvents(): void $invalidate = static function (Model&UserContract $user) use ($modelClass): void { $id = $user->getAuthIdentifier(); $identifierSegment = static::resolveCacheKeyIdentifier($id, $modelClass, $user); + $connection = $user->getConnection(); - $cacheManager = Container::getInstance()->make('cache'); + $callback = static function () use ($identifierSegment, $modelClass): void { + $cacheManager = Container::getInstance()->make('cache'); - foreach (static::$cachedProviders[$modelClass] ?? [] as $descriptor) { - $cacheManager - ->store($descriptor['storeName']) - ->forget($descriptor['prefix'] . ':' . $modelClass . ':' . $identifierSegment); + foreach (static::$cachedProviders[$modelClass] ?? [] as $descriptor) { + $cacheManager + ->store($descriptor['storeName']) + ->forget($descriptor['prefix'] . ':' . $modelClass . ':' . $identifierSegment); + } + }; + + if ($connection->getTransactionManager() === null && $connection->transactionLevel() === 0) { + $callback(); + + return; } + + $connection->afterCommit($callback); }; $modelClass::saved($invalidate); diff --git a/src/boost/docs/broadcasting.md b/src/boost/docs/broadcasting.md index b70c85989..7e7d84b96 100644 --- a/src/boost/docs/broadcasting.md +++ b/src/boost/docs/broadcasting.md @@ -849,7 +849,7 @@ public function broadcastWhen(): bool When broadcast events are dispatched within database transactions, they may be processed by the queue before the database transaction has committed. When this happens, any updates you have made to models or database records during the database transaction may not yet be reflected in the database. In addition, any models or database records created within the transaction may not exist in the database. If your event depends on these models, unexpected errors can occur when the job that broadcasts the event is processed. -If your queue connection's `after_commit` configuration option is set to `false`, you may still indicate that a particular broadcast event should be dispatched after all open database transactions have been committed by implementing the `ShouldDispatchAfterCommit` interface on the event class: +If your queue connection's `after_commit` configuration option is set to `false`, you may still indicate that a particular broadcast event should be dispatched after the open parent database transactions have committed by implementing the `ShouldDispatchAfterCommit` interface on the event class: ```php user())->send( diff --git a/src/boost/docs/notifications.md b/src/boost/docs/notifications.md index d35b2e450..d90715f91 100644 --- a/src/boost/docs/notifications.md +++ b/src/boost/docs/notifications.md @@ -370,7 +370,7 @@ public function middleware(object $notifiable, string $channel) When queued notifications are dispatched within database transactions, they may be processed by the queue before the database transaction has committed. When this happens, any updates you have made to models or database records during the database transaction may not yet be reflected in the database. In addition, any models or database records created within the transaction may not exist in the database. If your notification depends on these models, unexpected errors can occur when the job that sends the queued notification is processed. -If your queue connection's `after_commit` configuration option is set to `false`, you may still indicate that a particular queued notification should be dispatched after all open database transactions have been committed by calling the `afterCommit` method when sending the notification: +If your queue connection's `after_commit` configuration option is set to `false`, you may still indicate that a particular queued notification should be dispatched after the open parent database transactions have committed by calling the `afterCommit` method when sending the notification: ```php use App\Notifications\InvoicePaid; diff --git a/src/boost/docs/queues.md b/src/boost/docs/queues.md index 3cd8b21b9..f11e61cab 100644 --- a/src/boost/docs/queues.md +++ b/src/boost/docs/queues.md @@ -1354,13 +1354,15 @@ When the `after_commit` option is `true`, you may dispatch jobs within database If a transaction is rolled back due to an exception that occurs during the transaction, the jobs that were dispatched during that transaction will be discarded. +After-commit work is associated with the most recently started open transaction and runs once it and every transaction enclosing it on the same connection have committed. It does not wait for transactions on other connections. If a job depends on work across connections, dispatch it only after the other dependencies have committed or while the transaction that will commit last remains open. + > [!NOTE] -> Setting the `after_commit` configuration option to `true` will also cause any queued event listeners, mailables, notifications, and broadcast events to be dispatched after all open database transactions have been committed. +> Setting the `after_commit` configuration option to `true` will also cause any queued event listeners, mailables, notifications, and broadcast events to be dispatched after the open parent database transactions have committed. #### Specifying Commit Dispatch Behavior Inline -If you do not set the `after_commit` queue connection configuration option to `true`, you may still indicate that a specific job should be dispatched after all open database transactions have been committed. To accomplish this, you may chain the `afterCommit` method onto your dispatch operation: +If you do not set the `after_commit` queue connection configuration option to `true`, you may still indicate that a specific job should be dispatched after the open parent database transactions have committed. To accomplish this, you may chain the `afterCommit` method onto your dispatch operation: ```php use App\Jobs\ProcessPodcast; diff --git a/src/boost/docs/sanctum.md b/src/boost/docs/sanctum.md index c58d1849c..560ace0ea 100644 --- a/src/boost/docs/sanctum.md +++ b/src/boost/docs/sanctum.md @@ -151,6 +151,8 @@ public function boot(): void } ``` +If your user model needs to customize Sanctum's token relationship, override the protected `newTokenRelation` method. Sanctum uses this dedicated factory instead of the model-wide `newMorphMany` hook. + ### Last Used Timestamps @@ -213,7 +215,7 @@ For Redis, `SERIALIZER_NONE`, native PHP, and available igbinary serializers pre Sanctum cache settings and `sanctum.last_used_at` are read during process startup and must not be changed while a worker is serving requests. -Sanctum also caches missing token IDs as `null` results for the configured TTL. This protects your database from repeated lookups for the same revoked or unknown token. Missing tokenable models are not cached because their visibility may depend on the current query context. Because token IDs come from request input, use a cache store with bounded memory or an eviction policy when enabling token caching on public endpoints. +Sanctum also caches missing token IDs as `null` results for the configured TTL. This protects your database from repeated lookups for the same revoked or unknown token. Missing tokenable models are not cached because their visibility may depend on the current query context. Because token IDs come from request input, use a cache store with bounded memory or an eviction policy when enabling token caching on public endpoints. Continue to apply your application's normal rate limiting; the cache is not a replacement for request throttling. The `last_used_at_update_interval` option controls how frequently Sanctum writes a cached token's `last_used_at` timestamp back to the database. The default value is `300`, so the timestamp is updated at most once every five minutes for each token while caching is enabled. The cache TTL should be greater than or equal to this interval so active cached tokens do not expire before the next allowed timestamp write. @@ -221,22 +223,38 @@ Sanctum token caching pairs well with the authentication package's [user lookup The cached token entry never embeds its `tokenable` relation. During authentication, the live token receives the exact tokenable instance used for provider validation before authentication callbacks and events run. -Deleting a personal access token or making an application-visible update clears both cached entries. Sanctum's internal `last_used_at` write clears only the token entry, so it does not defeat the tokenable cache. You may also clear both entries manually using the `clearTokenCache` method: +Creating, updating, or deleting a personal access token automatically invalidates its affected cache entries after the database transaction commits. This includes soft deletes, restores, and force deletes performed on token model instances. Deleting through the token relation also invalidates every matched token when caching is enabled: ```php -use Hypervel\Sanctum\PersonalAccessToken; +$user->tokens()->delete(); + +$user->tokens()->where('name', 'Temporary')->delete(); +``` + +The relation fixes the matched token set before deletion and invalidates exactly those tokens. A token created concurrently after matching is not included in the deletion. Invalidations run immediately when there is no transaction, wait for the outer commit when there is one, and are discarded on rollback. + +Sanctum's internal `last_used_at` write refreshes only the token entry, so it does not defeat the tokenable cache. Raw SQL, quiet or eventless model mutations, arbitrary builder updates, and bulk restores bypass automatic invalidation. Clear both entries explicitly when using those escape hatches: + +```php +use Hypervel\Sanctum\Sanctum; -PersonalAccessToken::clearTokenCache($tokenId); +$tokenModel = Sanctum::personalAccessTokenModel(); + +$tokenModel::clearTokenCache($tokenId); ``` +The `sanctum:prune-expired` command also deletes records without immediately clearing their cache entries. These tokens are already expired and cannot authenticate, and their cache entries are removed when the configured cache TTL elapses. + Tokenable model changes do not automatically evict token-ID-keyed entries. The cache TTL is therefore the maximum staleness bound. If a change must be reflected immediately during token authentication, clear the cache for that model's tokens: ```php -use Hypervel\Sanctum\PersonalAccessToken; +use Hypervel\Sanctum\Sanctum; + +$tokenModel = Sanctum::personalAccessTokenModel(); $user->tokens() ->pluck('id') - ->each(fn (int|string $tokenId) => PersonalAccessToken::clearTokenCache($tokenId)); + ->each(fn (int|string $tokenId) => $tokenModel::clearTokenCache($tokenId)); ``` @@ -573,6 +591,8 @@ axios.get('/sanctum/csrf-cookie').then(response => { }); ``` +You may change the `sanctum` route prefix using the string `sanctum.prefix` configuration value, or disable the route by setting the boolean `sanctum.routes` configuration value to `false`. + During this request, Hypervel will set an `XSRF-TOKEN` cookie containing the current CSRF token. This token should then be URL decoded and passed in an `X-XSRF-TOKEN` header on subsequent requests, which some HTTP client libraries like Axios and the Angular HttpClient will do automatically for you. If your JavaScript HTTP library does not set the value for you, you will need to manually set the `X-XSRF-TOKEN` header to match the URL decoded value of the `XSRF-TOKEN` cookie that is set by this route. diff --git a/src/bus/src/Queueable.php b/src/bus/src/Queueable.php index 99edd2c96..c9c5d2622 100644 --- a/src/bus/src/Queueable.php +++ b/src/bus/src/Queueable.php @@ -57,7 +57,7 @@ trait Queueable public array|DateInterval|DateTimeInterface|int|null $delay = null; /** - * Indicates whether the job should be dispatched after all database transactions have committed. + * Indicates whether the job should be dispatched after the open parent database transactions have committed. */ public ?bool $afterCommit = null; @@ -187,7 +187,7 @@ public function withoutDelay(): static } /** - * Indicate that the job should be dispatched after all database transactions have committed. + * Indicate that the job should be dispatched after the open parent database transactions have committed. */ public function afterCommit(): static { diff --git a/src/database/src/Concerns/ManagesTransactions.php b/src/database/src/Concerns/ManagesTransactions.php index e37dfac9a..2118e0f2a 100644 --- a/src/database/src/Concerns/ManagesTransactions.php +++ b/src/database/src/Concerns/ManagesTransactions.php @@ -458,12 +458,14 @@ public function transactionLevel(): int /** * Execute the callback after a transaction commits. * + * The callback belongs to this connection's open transaction stack. + * * @throws RuntimeException */ public function afterCommit(callable $callback): void { if ($this->transactionsManager) { - $this->transactionsManager->addCallback($callback); + $this->transactionsManager->addCallback($callback, $this->getName()); return; } @@ -474,12 +476,14 @@ public function afterCommit(callable $callback): void /** * Execute the callback after a transaction rolls back. * + * The callback belongs to this connection's open transaction stack. + * * @throws RuntimeException */ public function afterRollBack(callable $callback): void { if ($this->transactionsManager) { - $this->transactionsManager->addCallbackForRollback($callback); + $this->transactionsManager->addCallbackForRollback($callback, $this->getName()); return; } diff --git a/src/database/src/Connection.php b/src/database/src/Connection.php index c59824562..8ecb73587 100755 --- a/src/database/src/Connection.php +++ b/src/database/src/Connection.php @@ -1658,6 +1658,9 @@ public function getTransactionManager(): ?DatabaseTransactionsManager /** * Unset the transaction manager for this connection. + * + * Tests only. A pooled connection keeps the null manager after release, so every + * later coroutine that borrows it fails when scheduling after-commit callbacks. */ public function unsetTransactionManager(): void { diff --git a/src/database/src/DatabaseTransactionsManager.php b/src/database/src/DatabaseTransactionsManager.php index 9deb4f0e7..568b43a63 100755 --- a/src/database/src/DatabaseTransactionsManager.php +++ b/src/database/src/DatabaseTransactionsManager.php @@ -306,7 +306,11 @@ protected function executeRollbackCallbacks(Collection $transactions): void /** * Register a transaction callback. * - * @param null|string $connection base name from Connection::getName(); null uses the latest applicable transaction across all connections + * A null connection selects the most recently started applicable transaction on any + * connection. The callback runs when that transaction and its enclosing stack on the + * same connection commit. + * + * @param null|string $connection base name from Connection::getName() */ public function addCallback(callable $callback, ?string $connection = null): void { @@ -321,7 +325,11 @@ public function addCallback(callable $callback, ?string $connection = null): voi /** * Register a callback for transaction rollback. * - * @param null|string $connection base name from Connection::getName(); null uses the latest applicable transaction across all connections + * A null connection selects the most recently started applicable transaction on any + * connection. The callback runs when that transaction and its enclosing stack on the + * same connection roll back. + * + * @param null|string $connection base name from Connection::getName() */ public function addCallbackForRollback(callable $callback, ?string $connection = null): void { diff --git a/src/database/src/Eloquent/BroadcastableModelEventOccurred.php b/src/database/src/Eloquent/BroadcastableModelEventOccurred.php index 8561da6b5..9d6ad6e74 100644 --- a/src/database/src/Eloquent/BroadcastableModelEventOccurred.php +++ b/src/database/src/Eloquent/BroadcastableModelEventOccurred.php @@ -31,7 +31,7 @@ class BroadcastableModelEventOccurred implements ShouldBroadcast public ?string $queue = null; /** - * Indicates whether the job should be dispatched after all database transactions have committed. + * Indicates whether the job should be dispatched after the open parent database transactions have committed. */ public bool $afterCommit = false; diff --git a/src/database/src/Eloquent/BroadcastsEvents.php b/src/database/src/Eloquent/BroadcastsEvents.php index cc9b368ea..eeca2d6b0 100644 --- a/src/database/src/Eloquent/BroadcastsEvents.php +++ b/src/database/src/Eloquent/BroadcastsEvents.php @@ -172,7 +172,7 @@ public function broadcastQueue(): ?string } /** - * Determine if the model event broadcast queued job should be dispatched after all transactions are committed. + * Determine if the model event broadcast queued job should be dispatched after the open parent transactions are committed. */ public function broadcastAfterCommit(): bool { diff --git a/src/database/src/Eloquent/BroadcastsEventsAfterCommit.php b/src/database/src/Eloquent/BroadcastsEventsAfterCommit.php index 2bbb0ddec..7511feb0d 100644 --- a/src/database/src/Eloquent/BroadcastsEventsAfterCommit.php +++ b/src/database/src/Eloquent/BroadcastsEventsAfterCommit.php @@ -9,7 +9,7 @@ trait BroadcastsEventsAfterCommit use BroadcastsEvents; /** - * Determine if the model event broadcast queued job should be dispatched after all transactions are committed. + * Determine if the model event broadcast queued job should be dispatched after the open parent transactions are committed. */ public function broadcastAfterCommit(): bool { diff --git a/src/events/src/Dispatcher.php b/src/events/src/Dispatcher.php index 549a0fe57..299ee57d4 100755 --- a/src/events/src/Dispatcher.php +++ b/src/events/src/Dispatcher.php @@ -836,7 +836,7 @@ protected function createQueuedHandlerCallable(string $class, string $method): C } /** - * Determine if the given event handler should be dispatched after all database transactions have committed. + * Determine if the given event handler should be dispatched after the open parent database transactions have committed. */ protected function handlerShouldBeDispatchedAfterDatabaseTransactions(mixed $listener): bool { diff --git a/src/foundation/src/Bus/PendingDispatch.php b/src/foundation/src/Bus/PendingDispatch.php index 117fd87bc..195637831 100644 --- a/src/foundation/src/Bus/PendingDispatch.php +++ b/src/foundation/src/Bus/PendingDispatch.php @@ -125,7 +125,7 @@ public function withoutDelay(): static } /** - * Indicate that the job should be dispatched after all database transactions have committed. + * Indicate that the job should be dispatched after the open parent database transactions have committed. */ public function afterCommit(): static { diff --git a/src/queue/src/Queue.php b/src/queue/src/Queue.php index 3d5281dc6..8019d0713 100644 --- a/src/queue/src/Queue.php +++ b/src/queue/src/Queue.php @@ -58,7 +58,7 @@ abstract class Queue protected array $config = []; /** - * Indicates that jobs should be dispatched after all database transactions have committed. + * Indicates that jobs should be dispatched after the open parent database transactions have committed. */ protected bool $dispatchAfterCommit = false; @@ -395,7 +395,7 @@ protected function enqueueNow(object|string $job, string $payload, ?string $queu } /** - * Determine if the job should be dispatched after all database transactions have committed. + * Determine if the job should be dispatched after the open parent database transactions have committed. * * @param Closure|object|string $job */ diff --git a/src/sanctum/README.md b/src/sanctum/README.md index 932c6e4cb..d1065d876 100644 --- a/src/sanctum/README.md +++ b/src/sanctum/README.md @@ -3,7 +3,7 @@ Sanctum for Hypervel [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/sanctum) -Ported from: https://github.com/laravel/sanctum +Documentation: https://hypervel.org/docs/sanctum ## Differences From Laravel @@ -11,7 +11,11 @@ Ported from: https://github.com/laravel/sanctum - Hypervel includes optional token and tokenable lookup caching for Swoole workers. Missing token IDs are cached as `null` for the configured TTL. Missing tokenable models are not cached because their visibility may depend on the current query context. - When caching is enabled, the selected personal-access-token model, configured Sanctum guard provider models, and stock Eloquent graph containers are added to the cache class policy automatically. Applications declare custom-provider morph targets, nested relations, custom containers, and other application-owned objects with `Cache::allowSerializableClassesUsing()` during provider boot. - Sanctum validates its cache store during process startup. Redis, database, file, storage, Swoole, and supported-only stacks are accepted; array, worker-array, null, session, failover, and type-destroying Redis serializer modes are rejected. Accepted native Redis serializers preserve model types but bypass the PHP class policy. -- Cached token entries never embed `tokenable`. The live token receives the exact resolved tokenable before callbacks and events. Deletion and application-visible token updates clear both entries, while Sanctum's internal `last_used_at` write clears only the token entry. The tokenable TTL is its maximum staleness bound. -- The global `sanctum.guard` accept-list is removed. Each sanctum-driver guard declares its trusted session guards with `auth.guards.{guard}.session_guards`; `[]` means bearer tokens only, and a missing key is a config error. Stateful session users must also match the sanctum guard's provider; Laravel returns any listed guard's user unchecked. +- Cached token entries never embed `tokenable`. Successful model creation, update, and deletion, plus cache-enabled deletion through the token relation, invalidate affected entries after commit. Sanctum's internal `last_used_at` write refreshes only the token entry. +- `HasApiTokens::tokens()` uses the protected `newTokenRelation()` factory instead of the model-wide `newMorphMany()` hook. +- Hypervel ships an explicit default `sanctum` guard. Laravel's global `sanctum.guard` accept-list is omitted; each Sanctum guard declares trusted session guards with `auth.guards.{guard}.session_guards`. An empty list means bearer tokens only, a missing key is a configuration error, and stateful users must match the Sanctum guard's provider instead of accepting any listed guard's user unchecked. +- Sanctum middleware priority is enabled by calling `Middleware::statefulApi()` in `bootstrap/app.php`; the package provider does not mutate global middleware priority. - `sanctum.stateful` is renamed `sanctum.stateful_domains`, matching the `SANCTUM_STATEFUL_DOMAINS` environment variable and the key's actual contents. - Sanctum's session password-hash artifacts are HMAC-only. Laravel's raw-hash fallback for legacy sessions is intentionally omitted because Hypervel 0.4 has no released legacy sessions. + +Ported from: https://github.com/laravel/sanctum diff --git a/src/sanctum/composer.json b/src/sanctum/composer.json index 49ddc8e96..296268619 100644 --- a/src/sanctum/composer.json +++ b/src/sanctum/composer.json @@ -24,6 +24,9 @@ ], "require": { "php": "^8.4", + "ext-ctype": "*", + "ext-filter": "*", + "ext-json": "*", "nesbot/carbon": "^3.13.1", "hypervel/auth": "^0.4", "hypervel/cache": "^0.4", @@ -32,11 +35,14 @@ "hypervel/container": "^0.4", "hypervel/context": "^0.4", "hypervel/contracts": "^0.4", + "hypervel/cookie": "^0.4", "hypervel/core": "^0.4", "hypervel/database": "^0.4", + "hypervel/foundation": "^0.4", "hypervel/http": "^0.4", "hypervel/macroable": "^0.4", "hypervel/routing": "^0.4", + "hypervel/session": "^0.4", "hypervel/support": "^0.4", "symfony/console": "^8.1", "symfony/http-foundation": "^8.1" diff --git a/src/sanctum/routes/web.php b/src/sanctum/routes/web.php deleted file mode 100644 index 4e1166008..000000000 --- a/src/sanctum/routes/web.php +++ /dev/null @@ -1,8 +0,0 @@ -name('sanctum.csrf-cookie'); diff --git a/src/sanctum/src/Console/Commands/PruneExpired.php b/src/sanctum/src/Console/Commands/PruneExpired.php index ca342f54c..e68dfc446 100644 --- a/src/sanctum/src/Console/Commands/PruneExpired.php +++ b/src/sanctum/src/Console/Commands/PruneExpired.php @@ -28,10 +28,19 @@ class PruneExpired extends Command */ public function handle(): int { - $model = Sanctum::$personalAccessTokenModel; + $model = Sanctum::personalAccessTokenModel(); - /** @var int $hours */ - $hours = (int) $this->option('hours'); + $hours = filter_var( + $this->option('hours'), + FILTER_VALIDATE_INT, + ['options' => ['min_range' => 0]], + ); + + if ($hours === false) { + $this->error('The --hours option must be a non-negative integer.'); + + return Command::FAILURE; + } $this->info('Pruning tokens with expired expires_at timestamps...'); @@ -49,6 +58,6 @@ public function handle(): int $this->info("Tokens expired for more than [{$hours} hours] pruned successfully."); - return 0; + return Command::SUCCESS; } } diff --git a/src/sanctum/src/HasApiTokens.php b/src/sanctum/src/HasApiTokens.php index 4671fef65..5cadd174e 100644 --- a/src/sanctum/src/HasApiTokens.php +++ b/src/sanctum/src/HasApiTokens.php @@ -5,7 +5,6 @@ namespace Hypervel\Sanctum; use DateTimeInterface; -use Hypervel\Database\Eloquent\Relations\MorphMany; use Hypervel\Sanctum\Contracts\HasAbilities; use Hypervel\Support\Str; use UnitEnum; @@ -27,11 +26,30 @@ trait HasApiTokens /** * Get the access tokens that belong to model. * - * @return MorphMany + * @return PersonalAccessTokenRelation<$this> */ - public function tokens(): MorphMany + public function tokens(): PersonalAccessTokenRelation { - return $this->morphMany(Sanctum::$personalAccessTokenModel, 'tokenable'); + return $this->newTokenRelation(); + } + + /** + * Instantiate the personal access token relationship. + */ + protected function newTokenRelation(): PersonalAccessTokenRelation + { + $instance = $this->newRelatedInstance(Sanctum::personalAccessTokenModel()); + [$type, $id] = $this->getMorphs('tokenable', null, null); + + // This relation owns Sanctum's cache-aware delete contract without + // replacing the model-wide newMorphMany() extension point. + return new PersonalAccessTokenRelation( + $instance->newQuery(), + $this, + $instance->qualifyColumn($type), + $instance->qualifyColumn($id), + $this->getKeyName(), + ); } /** diff --git a/src/sanctum/src/Http/Middleware/EnsureFrontendRequestsAreStateful.php b/src/sanctum/src/Http/Middleware/EnsureFrontendRequestsAreStateful.php index 7f8c72100..edb4563b7 100644 --- a/src/sanctum/src/Http/Middleware/EnsureFrontendRequestsAreStateful.php +++ b/src/sanctum/src/Http/Middleware/EnsureFrontendRequestsAreStateful.php @@ -61,7 +61,9 @@ protected function frontendMiddleware(): array $filtered = []; foreach ($middleware as $candidate) { - if ($candidate && ! in_array($candidate, $filtered, true)) { + if ($candidate !== null + && $candidate !== '' + && ! in_array($candidate, $filtered, true)) { $filtered[] = $candidate; } } diff --git a/src/sanctum/src/PersonalAccessToken.php b/src/sanctum/src/PersonalAccessToken.php index 842c2417c..8fb905c6a 100644 --- a/src/sanctum/src/PersonalAccessToken.php +++ b/src/sanctum/src/PersonalAccessToken.php @@ -5,6 +5,7 @@ namespace Hypervel\Sanctum; use Carbon\CarbonInterface; +use Closure; use Hypervel\Container\Container; use Hypervel\Contracts\Auth\Authenticatable; use Hypervel\Contracts\Cache\Repository as CacheRepository; @@ -67,26 +68,48 @@ protected static function boot(): void { parent::boot(); - static::updating(function (PersonalAccessToken $model): void { + static::created(function (self $token): void { if (! config('sanctum.cache.enabled')) { return; } - // Eloquent fires updating before adding updated_at, so this exact - // dirty set identifies Sanctum's internal audit write. - if (array_keys($model->getDirty()) === ['last_used_at']) { - self::forgetTokenEntry(self::getCache(), $model->id); + /** @var int|string $id */ + $id = $token->getKey(); + $token->settleCacheMutation( + fn () => static::forgetTokenEntry(static::getCache(), $id) + ); + }); + + static::updated(function (self $token): void { + if (! config('sanctum.cache.enabled')) { return; } - self::clearTokenCache($model->id); + /** @var int|string $id */ + $id = $token->getKey(); + $lastUsedAtOnly = $token->wasOnlyLastUsedAtChanged(); + + $token->settleCacheMutation(function () use ($id, $lastUsedAtOnly): void { + if ($lastUsedAtOnly) { + static::forgetTokenEntry(static::getCache(), $id); + + return; + } + + static::clearTokenCache($id); + }); }); - static::deleting(function (PersonalAccessToken $model): void { - if (config('sanctum.cache.enabled')) { - self::clearTokenCache($model->id); + static::deleted(function (self $token): void { + if (! config('sanctum.cache.enabled')) { + return; } + + /** @var int|string $id */ + $id = $token->getKey(); + + $token->settleCacheMutation(fn () => static::clearTokenCache($id)); }); } @@ -103,7 +126,7 @@ public function tokenable(): MorphTo */ public static function findToken(string $token): ?static { - if (strpos($token, '|') === false) { + if (! str_contains($token, '|')) { // Hypervel only supports the id|token format created by createToken(). // Laravel's legacy plain-token lookup is intentionally omitted because // Sanctum's cache and invalidation paths are keyed by token ID. @@ -112,12 +135,17 @@ public static function findToken(string $token): ?static [$id, $plainToken] = explode('|', $token, 2); - if (! static::isValidTokenIdentifier($id)) { + if ($id === '' || $plainToken === '') { + return null; + } + + if ((new static)->getKeyType() === 'int' + && (! ctype_digit($id) || filter_var($id, FILTER_VALIDATE_INT) === false)) { return null; } $accessToken = config('sanctum.cache.enabled') - ? self::findTokenUsingCache($id) + ? static::findTokenUsingCache($id) : static::find($id); if (! $accessToken) { @@ -136,10 +164,10 @@ public static function findToken(string $token): ?static */ protected static function findTokenUsingCache(string $id): ?static { - $cache = self::getCache(); + $cache = static::getCache(); return $cache->rememberNullable( - self::getCacheKey($id), + static::getCacheKey($id), config('sanctum.cache.ttl'), fn () => static::find($id)?->unsetRelation('tokenable') ); @@ -158,8 +186,10 @@ public static function findTokenable(PersonalAccessToken $accessToken): ?Authent return $accessToken->getAttribute('tokenable'); } - $cache = self::getCache(); - $cacheKey = self::getCacheKey($accessToken->id) . ':tokenable'; + $cache = static::getCache(); + /** @var int|string $id */ + $id = $accessToken->getKey(); + $cacheKey = static::getCacheKey($id) . ':tokenable'; // A scoped miss may be visible in another query context, so cache only positive tokenables. $tokenable = $cache->get($cacheKey); @@ -179,18 +209,6 @@ public static function findTokenable(PersonalAccessToken $accessToken): ?Authent return $tokenable; } - /** - * Determine if the token identifier can be queried by this model. - */ - protected static function isValidTokenIdentifier(string $id): bool - { - if ($id === '') { - return false; - } - - return (new static)->getKeyType() !== 'int' || ctype_digit($id); - } - /** * Determine if the token has a given ability. */ @@ -198,8 +216,8 @@ public function can(UnitEnum|string $ability): bool { $ability = enum_value($ability); - return in_array('*', $this->abilities) - || array_key_exists($ability, array_flip($this->abilities)); + return in_array('*', $this->abilities, true) + || in_array($ability, $this->abilities, true); } /** @@ -215,9 +233,9 @@ public function cant(UnitEnum|string $ability): bool */ public static function clearTokenCache(int|string $tokenId): void { - $cache = self::getCache(); - self::forgetTokenEntry($cache, $tokenId); - $cache->forget(self::getCacheKey($tokenId) . ':tokenable'); + $cache = static::getCache(); + static::forgetTokenEntry($cache, $tokenId); + $cache->forget(static::getCacheKey($tokenId) . ':tokenable'); } /** @@ -225,7 +243,7 @@ public static function clearTokenCache(int|string $tokenId): void */ protected static function forgetTokenEntry(CacheRepository $cache, int|string $tokenId): void { - $cache->forget(self::getCacheKey($tokenId)); + $cache->forget(static::getCacheKey($tokenId)); } /** @@ -261,14 +279,47 @@ public function updateLastUsedAt(): void $connection->setRecordModificationState($hasModifiedRecords); if ($cacheEnabled) { - static::getCache()->put( - static::getCacheKey($this->id), - $this->withoutRelation('tokenable'), - config('sanctum.cache.ttl'), + /** @var int|string $id */ + $id = $this->getKey(); + $snapshot = $this->withoutRelation('tokenable'); + $ttl = config('sanctum.cache.ttl'); + + $this->settleCacheMutation( + fn () => static::getCache()->put(static::getCacheKey($id), $snapshot, $ttl) ); } } + /** + * Determine whether only the last-used timestamp changed. + */ + protected function wasOnlyLastUsedAtChanged(): bool + { + $changes = $this->getChanges(); + + if (($updatedAt = $this->getUpdatedAtColumn()) !== null) { + unset($changes[$updatedAt]); + } + + return array_keys($changes) === ['last_used_at']; + } + + /** + * Run a cache mutation after its database transaction settles. + */ + protected function settleCacheMutation(Closure $callback): void + { + $connection = $this->getConnection(); + + if ($connection->getTransactionManager() === null && $connection->transactionLevel() === 0) { + $callback(); + + return; + } + + $connection->afterCommit($callback); + } + /** * Get cache instance. */ diff --git a/src/sanctum/src/PersonalAccessTokenRelation.php b/src/sanctum/src/PersonalAccessTokenRelation.php new file mode 100644 index 000000000..0e5d0f471 --- /dev/null +++ b/src/sanctum/src/PersonalAccessTokenRelation.php @@ -0,0 +1,66 @@ + + */ +class PersonalAccessTokenRelation extends MorphMany +{ + /** + * Delete the related personal access tokens. + */ + public function delete(): mixed + { + if (! config('sanctum.cache.enabled')) { + return $this->getQuery()->delete(); + } + + /** @var array $ids */ + $ids = (clone $this->getQuery()) + ->pluck($this->getRelated()->getQualifiedKeyName()) + ->all(); + + if ($ids === []) { + return 0; + } + + $deleted = (clone $this->getQuery())->whereKey($ids)->delete(); + + $this->settleInvalidation($ids); + + return $deleted; + } + + /** + * Clear the selected tokens after their database transaction settles. + * + * @param array $ids + */ + protected function settleInvalidation(array $ids): void + { + $related = $this->getRelated(); + $connection = $related->getConnection(); + $tokenModel = $related::class; + $callback = static function () use ($ids, $tokenModel): void { + foreach ($ids as $id) { + $tokenModel::clearTokenCache($id); + } + }; + + if ($connection->getTransactionManager() === null && $connection->transactionLevel() === 0) { + $callback(); + + return; + } + + $connection->afterCommit($callback); + } +} diff --git a/src/sanctum/src/Sanctum.php b/src/sanctum/src/Sanctum.php index 5329fcf5f..fcc38f045 100644 --- a/src/sanctum/src/Sanctum.php +++ b/src/sanctum/src/Sanctum.php @@ -6,21 +6,24 @@ use Hypervel\Container\Container; use Hypervel\Contracts\Auth\Authenticatable; +use Hypervel\Sanctum\Contracts\HasAbilities; use Mockery; use Mockery\MockInterface; +use UnitEnum; + +use function Hypervel\Support\enum_value; -/** - * @template TToken of \Hypervel\Sanctum\Contracts\HasAbilities = \Hypervel\Sanctum\PersonalAccessToken - */ class Sanctum { /** @var class-string */ protected const string DEFAULT_PERSONAL_ACCESS_TOKEN_MODEL = PersonalAccessToken::class; + protected const string DEFAULT_CURRENT_REQUEST_HOST_PLACEHOLDER = '__SANCTUM_CURRENT_REQUEST_HOST__'; + /** * The personal access client model class name. * - * @var class-string + * @var class-string */ public static string $personalAccessTokenModel = self::DEFAULT_PERSONAL_ACCESS_TOKEN_MODEL; @@ -41,7 +44,7 @@ class Sanctum /** * A placeholder to instruct Sanctum to include the current request host in the list of stateful domains. */ - public static string $currentRequestHostPlaceholder = '__SANCTUM_CURRENT_REQUEST_HOST__'; + public static string $currentRequestHostPlaceholder = self::DEFAULT_CURRENT_REQUEST_HOST_PLACEHOLDER; /** * Get the current application URL from the "APP_URL" environment variable - with port. @@ -73,32 +76,44 @@ public static function supportsTokens(?Authenticatable $tokenable): bool /** * Set the current user for the application with the given abilities. * - * @param \Hypervel\Contracts\Auth\Authenticatable&\Hypervel\Sanctum\Contracts\HasApiTokens $user - * @param array $abilities + * Tests only. This installs a Mockery token double and replaces the current + * coroutine's authenticated user and default guard with test state. + * + * @template TUser of Authenticatable + * + * @param TUser $user + * @param array $abilities + * @return TUser */ - public static function actingAs($user, array $abilities = [], string $guard = 'sanctum'): mixed + public static function actingAs(Authenticatable $user, array $abilities = [], string $guard = 'sanctum'): Authenticatable { - /** @var \Hypervel\Sanctum\Contracts\HasAbilities&MockInterface $token */ - $token = Mockery::mock(self::personalAccessTokenModel())->shouldIgnoreMissing(false); + $abilities = array_map(enum_value(...), $abilities); + + /** @var HasAbilities&MockInterface $token */ + $token = Mockery::mock(static::personalAccessTokenModel())->shouldIgnoreMissing(false); - if (in_array('*', $abilities)) { + if (in_array('*', $abilities, true)) { $token->shouldReceive('can')->andReturn(true); } else { - /* @phpstan-ignore-next-line */ - $token->shouldReceive('can')->andReturnUsing(function (string $ability) use ($abilities) { - return in_array($ability, $abilities); + $expectation = $token->shouldReceive('can'); + // @phpstan-ignore method.notFound (A named shouldReceive() returns an expectation, not HigherOrderMessage.) + $expectation->andReturnUsing(function (UnitEnum|string $ability) use ($abilities): bool { + return in_array(enum_value($ability), $abilities, true); }); } + // @phpstan-ignore method.notFound (The documented HasApiTokens trait provides this method.) $user->withAccessToken($token); + // @phpstan-ignore property.notFound (Eloquent and compatible authenticatables expose this testing flag.) if (isset($user->wasRecentlyCreated) && $user->wasRecentlyCreated) { + // @phpstan-ignore property.notFound (Eloquent and compatible authenticatables expose this testing flag.) $user->wasRecentlyCreated = false; } - // Set the user on the guard $authManager = Container::getInstance()->make('auth'); $authManager->guard($guard)->setUser($user); + $authManager->shouldUse($guard); return $user; } @@ -110,7 +125,7 @@ public static function actingAs($user, array $abilities = [], string $guard = 's * worker lifetime and is used for every token resolution across all * coroutines. * - * @param class-string $model + * @param class-string $model */ public static function usePersonalAccessTokenModel(string $model): void { @@ -142,7 +157,7 @@ public static function authenticateAccessTokensUsing(callable $callback): void /** * Get the token model class name. * - * @return class-string + * @return class-string */ public static function personalAccessTokenModel(): string { @@ -157,5 +172,6 @@ public static function flushState(): void static::$personalAccessTokenModel = self::DEFAULT_PERSONAL_ACCESS_TOKEN_MODEL; static::$accessTokenRetrievalCallback = null; static::$accessTokenAuthenticationCallback = null; + static::$currentRequestHostPlaceholder = self::DEFAULT_CURRENT_REQUEST_HOST_PLACEHOLDER; } } diff --git a/src/sanctum/src/SanctumGuard.php b/src/sanctum/src/SanctumGuard.php index 0c9eed9ee..c73478c0d 100644 --- a/src/sanctum/src/SanctumGuard.php +++ b/src/sanctum/src/SanctumGuard.php @@ -13,6 +13,7 @@ use Hypervel\Contracts\Auth\UserProvider; use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Events\Dispatcher; +use Hypervel\Http\Request; use Hypervel\Sanctum\Events\TokenAuthenticated; use Hypervel\Support\Traits\Macroable; use InvalidArgumentException; @@ -163,53 +164,14 @@ protected function getTokenFromRequest(): ?string return null; } + /** @var Request $request */ $request = $this->app->make('request'); if (is_callable(Sanctum::$accessTokenRetrievalCallback)) { return (string) (Sanctum::$accessTokenRetrievalCallback)($request); } - $token = $this->getBearerToken($request); - - return $this->isValidBearerToken($token) ? $token : null; - } - - /** - * Get the bearer token from the request headers. - */ - protected function getBearerToken(mixed $request): ?string - { - $header = $request->header('Authorization', ''); - - if (str_starts_with($header, 'Bearer ')) { - return substr($header, 7); - } - - // Check for token in request input as fallback - if ($request->has('token')) { - return $request->input('token'); - } - - return null; - } - - /** - * Determine if the bearer token is in the correct format. - */ - protected function isValidBearerToken(?string $token = null): bool - { - if (! is_null($token) && str_contains($token, '|')) { - $model = new (Sanctum::$personalAccessTokenModel)(); - - // @phpstan-ignore function.alreadyNarrowedType (custom token models may not extend Model) - if (method_exists($model, 'getKeyType') && $model->getKeyType() === 'int') { - [$id, $token] = explode('|', $token, 2); - - return ctype_digit($id) && ! empty($token); - } - } - - return ! empty($token); + return $request->bearerToken(); } /** diff --git a/src/sanctum/src/SanctumServiceProvider.php b/src/sanctum/src/SanctumServiceProvider.php index 15157ea5e..7e37e5f8f 100644 --- a/src/sanctum/src/SanctumServiceProvider.php +++ b/src/sanctum/src/SanctumServiceProvider.php @@ -9,6 +9,7 @@ use Hypervel\Cache\ModelCacheStoreValidator; use Hypervel\Contracts\Auth\Authenticatable; use Hypervel\Contracts\Config\Repository as ConfigRepository; +use Hypervel\Contracts\Container\Container; use Hypervel\Core\Events\AfterWorkerStart; use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Database\Eloquent\Model; @@ -16,6 +17,7 @@ use Hypervel\Database\Eloquent\Relations\Pivot; use Hypervel\Http\Request; use Hypervel\Sanctum\Console\Commands\PruneExpired; +use Hypervel\Sanctum\Http\Controllers\CsrfCookieController; use Hypervel\Session\Middleware\StartSession; use Hypervel\Support\Facades\Route; use Hypervel\Support\ServiceProvider; @@ -102,15 +104,14 @@ public function boot(): void }); } - $this->registerSanctumGuard(); - $this->configureSessionCookies(); - if ($this->app->runningInConsole()) { $this->registerPublishing(); $this->registerCommands(); } - $this->registerRoutes(); + $this->defineRoutes(); + $this->configureGuard(); + $this->configureMiddleware(); } /** @@ -149,40 +150,85 @@ private function validateCacheConfiguration(CacheManager $cache, ConfigRepositor } /** - * Register the Sanctum authentication guard. + * Define the Sanctum routes. */ - protected function registerSanctumGuard(): void + protected function defineRoutes(): void { - $this->callAfterResolving(AuthManager::class, function (AuthManager $authManager) { - $authManager->extend('sanctum', function ($app, $name, $config) use ($authManager) { - $sessionGuards = $config['session_guards'] ?? null; - $isSessionGuardName = static fn (mixed $guard): bool => is_string($guard) && $guard !== ''; + if ($this->app->routesAreCached()) { + return; + } - if (! is_array($sessionGuards) || array_filter($sessionGuards, $isSessionGuardName) !== $sessionGuards) { - throw new InvalidArgumentException( - "Auth guard [{$name}] uses the sanctum driver but does not declare a valid session guards list. " - . "Set auth.guards.{$name}.session_guards to an array of session guard names, or [] to disable stateful session authentication." - ); - } + $config = $this->app->make(ConfigRepository::class); - return new SanctumGuard( - name: $name, - provider: $authManager->createUserProvider($config['provider'] ?? null), - app: $app, - sessionGuards: $sessionGuards, - events: $app->bound('events') ? $app->make('events') : null, - expiration: $app->make('config')->get('sanctum.expiration'), - trackLastUsedAt: $app->make('config')->boolean('sanctum.last_used_at'), - ); - }); + if (! $config->boolean('sanctum.routes', true)) { + return; + } + + Route::group(['prefix' => $config->string('sanctum.prefix', 'sanctum')], function (): void { + Route::get('/csrf-cookie', [CsrfCookieController::class, 'show']) + ->middleware('web') + ->name('sanctum.csrf-cookie'); }); } /** - * Configure session cookies for stateful frontend requests. + * Configure the Sanctum authentication guard. */ - protected function configureSessionCookies(): void + protected function configureGuard(): void { + $this->callAfterResolving(AuthManager::class, function (AuthManager $authManager): void { + // AuthManager rebinds custom creators to itself, so keep the + // protected provider extension point behind its original scope. + $createGuard = fn (Container $app, string $name, array $config): SanctumGuard => $this->createGuard( + $authManager, + $app, + $name, + $config, + ); + + $authManager->extend( + 'sanctum', + static fn (Container $app, string $name, array $config): SanctumGuard => $createGuard($app, $name, $config), + ); + }); + } + + /** + * Create a new Sanctum guard instance. + */ + protected function createGuard( + AuthManager $authManager, + Container $app, + string $name, + array $config, + ): SanctumGuard { + $sessionGuards = $config['session_guards'] ?? null; + $isSessionGuardName = static fn (mixed $guard): bool => is_string($guard) && $guard !== ''; + + if (! is_array($sessionGuards) || array_filter($sessionGuards, $isSessionGuardName) !== $sessionGuards) { + throw new InvalidArgumentException( + "Auth guard [{$name}] uses the sanctum driver but does not declare a valid session guards list. " + . "Set auth.guards.{$name}.session_guards to an array of session guard names, or [] to disable stateful session authentication." + ); + } + + return new SanctumGuard( + name: $name, + provider: $authManager->createUserProvider($config['provider'] ?? null), + app: $app, + sessionGuards: $sessionGuards, + events: $app->bound('events') ? $app->make('events') : null, + expiration: $app->make('config')->get('sanctum.expiration'), + trackLastUsedAt: $app->make('config')->boolean('sanctum.last_used_at'), + ); + } + + /** + * Configure Sanctum's middleware behavior. + */ + protected function configureMiddleware(): void + { + // Middleware::statefulApi() owns priority configuration before the HTTP kernel is built. StartSession::configureSessionCookieUsing(function (Request $request, array $cookie): array { if (! $request->attributes->get('sanctum')) { return $cookie; @@ -195,15 +241,6 @@ protected function configureSessionCookies(): void }); } - /** - * Register the package routes. - */ - protected function registerRoutes(): void - { - Route::middleware(config('sanctum.middleware', 'web')) - ->group(__DIR__ . '/../routes/web.php'); - } - /** * Register the package's publishable resources. */ diff --git a/src/scout/config/scout.php b/src/scout/config/scout.php index da7a59ba5..da6fc062e 100644 --- a/src/scout/config/scout.php +++ b/src/scout/config/scout.php @@ -59,7 +59,7 @@ |-------------------------------------------------------------------------- | | This option determines if your data will only be synced with your search - | indexes after every open database transaction has been committed. This + | indexes after the open parent database transactions have committed. This | prevents discarded data from being synchronized with your indexes. | */ diff --git a/src/scout/src/ModelObserver.php b/src/scout/src/ModelObserver.php index 257496ebe..0da490e5f 100644 --- a/src/scout/src/ModelObserver.php +++ b/src/scout/src/ModelObserver.php @@ -31,7 +31,7 @@ class ModelObserver protected const FORCE_SAVING_CONTEXT_KEY = '__scout.force_saving'; /** - * Indicates if Scout will dispatch the observer's events after all database transactions have committed. + * Indicates if Scout will dispatch the observer's events after the open parent database transactions have committed. */ public bool $afterCommit; diff --git a/tests/Database/DatabaseTransactionsTest.php b/tests/Database/DatabaseTransactionsTest.php index 6c47ccd97..1d0148535 100644 --- a/tests/Database/DatabaseTransactionsTest.php +++ b/tests/Database/DatabaseTransactionsTest.php @@ -9,6 +9,7 @@ use Hypervel\Database\DatabaseTransactionsManager; use Hypervel\Tests\TestCase; use Mockery as m; +use RuntimeException; use Throwable; class DatabaseTransactionsTest extends TestCase @@ -18,6 +19,8 @@ class DatabaseTransactionsTest extends TestCase */ protected function setUp(): void { + parent::setUp(); + $db = new DB; $db->addConnection([ @@ -239,6 +242,52 @@ public function testNestedTransactionsAreRolledBack() } } + public function testAfterCommitRegistersTheCurrentConnectionName(): void + { + $transactionManager = m::mock(new DatabaseTransactionsManager); + $callback = static function (): void { + }; + + $transactionManager->shouldReceive('addCallback') + ->once() + ->with($callback, 'second_connection'); + + $this->connection('second_connection')->setTransactionManager($transactionManager); + $this->connection('second_connection')->afterCommit($callback); + } + + public function testAfterRollbackRegistersTheCurrentConnectionName(): void + { + $transactionManager = m::mock(new DatabaseTransactionsManager); + $callback = static function (): void { + }; + + $transactionManager->shouldReceive('addCallbackForRollback') + ->once() + ->with($callback, 'second_connection'); + + $this->connection('second_connection')->setTransactionManager($transactionManager); + $this->connection('second_connection')->afterRollBack($callback); + } + + public function testAfterCommitRequiresATransactionManager(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Transactions Manager has not been set.'); + + $this->connection()->afterCommit(static function (): void { + }); + } + + public function testAfterRollbackRequiresATransactionManager(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Transactions Manager has not been set.'); + + $this->connection()->afterRollBack(static function (): void { + }); + } + /** * Get a schema builder instance. */ diff --git a/tests/Integration/Auth/EloquentUserProviderCacheTest.php b/tests/Integration/Auth/EloquentUserProviderCacheTest.php index b30e7b098..b74be3f32 100644 --- a/tests/Integration/Auth/EloquentUserProviderCacheTest.php +++ b/tests/Integration/Auth/EloquentUserProviderCacheTest.php @@ -28,6 +28,7 @@ use Mockery as m; use Mockery\MockInterface; use ReflectionClass; +use RuntimeException; #[WithMigration] class EloquentUserProviderCacheTest extends TestCase @@ -97,6 +98,10 @@ protected function defineEnvironment(ApplicationContract $app): void 'store' => 'auth-file', ], ], + 'database.connections.auth_secondary' => [ + 'driver' => 'sqlite', + 'database' => ':memory:', + ], ]); } @@ -153,6 +158,165 @@ public function testCacheIsClearedOnUserDelete(): void $user->delete(); } + public function testCacheIsClearedOnlyAfterUserSaveCommits(): void + { + $user = User::query()->firstOrFail(); + $repo = $this->stubCache(); + $repo->shouldReceive('forget')->once()->with($this->buildKey($user->getAuthIdentifier()))->andReturnTrue(); + + $this->makeCachedProvider(); + + DB::transaction(function () use ($repo, $user): void { + $user->name = 'Updated'; + $user->save(); + + $repo->shouldNotHaveReceived('forget'); + }); + } + + public function testCacheIsClearedOnlyAfterUserDeleteCommits(): void + { + $user = User::query()->firstOrFail(); + $repo = $this->stubCache(); + $repo->shouldReceive('forget')->once()->with($this->buildKey($user->getAuthIdentifier()))->andReturnTrue(); + + $this->makeCachedProvider(); + + DB::transaction(function () use ($repo, $user): void { + $user->delete(); + + $repo->shouldNotHaveReceived('forget'); + }); + } + + public function testCacheInvalidationIsDiscardedWhenUserSaveRollsBack(): void + { + $user = User::query()->firstOrFail(); + $repo = $this->stubCache(); + $repo->shouldNotReceive('forget'); + + $this->makeCachedProvider(); + + try { + DB::transaction(function () use ($user): void { + $user->name = 'Updated'; + $user->save(); + + throw new RuntimeException('rollback'); + }); + } catch (RuntimeException) { + // Ignore the expected rollback exception. + } + } + + public function testCacheInvalidationFollowsTheUserConnectionTransaction(): void + { + $user = User::query()->firstOrFail(); + $repo = $this->stubCache(); + $repo->shouldReceive('forget')->once()->with($this->buildKey($user->getAuthIdentifier()))->andReturnTrue(); + $default = DB::connection(); + $secondary = DB::connection('auth_secondary'); + + $this->makeCachedProvider(); + + $default->beginTransaction(); + $secondary->beginTransaction(); + + try { + $this->fireUserEvent('saved', $user); + + $secondary->commit(); + $repo->shouldNotHaveReceived('forget'); + + $default->commit(); + } finally { + if ($secondary->transactionLevel() > 0) { + $secondary->rollBack(); + } + + if ($default->transactionLevel() > 1) { + $default->rollBack(1); + } + } + } + + public function testCacheInvalidationRunsImmediatelyWithoutManagerOrTransaction(): void + { + $user = (new User)->setConnection('auth_secondary'); + $user->setRawAttributes(['id' => 1], true); + $repo = $this->stubCache(); + $repo->shouldReceive('forget')->once()->with($this->buildKey(1))->andReturnTrue(); + $connection = DB::connection('auth_secondary'); + $manager = $connection->getTransactionManager(); + + $this->makeCachedProvider(); + $connection->unsetTransactionManager(); + + try { + $this->fireUserEvent('saved', $user); + } finally { + $connection->setTransactionManager($manager); + } + } + + public function testCacheInvalidationFailsClosedWithoutManagerDuringTransaction(): void + { + $user = (new User)->setConnection('auth_secondary'); + $user->setRawAttributes(['id' => 1], true); + $this->stubCache()->shouldNotReceive('forget'); + $connection = DB::connection('auth_secondary'); + $manager = $connection->getTransactionManager(); + + $this->makeCachedProvider(); + $connection->unsetTransactionManager(); + $connection->beginTransaction(); + + try { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Transactions Manager has not been set.'); + + $this->fireUserEvent('saved', $user); + } finally { + $connection->rollBack(); + $connection->setTransactionManager($manager); + } + } + + public function testCacheInvalidationCapturesTheEventKeyAndReadsDescriptorsAtCommit(): void + { + $user = User::query()->firstOrFail(); + $scope = 'before'; + $resolverCalls = 0; + + EloquentUserProvider::resolveUserCacheKeyUsing(function (mixed $identifier) use (&$resolverCalls, &$scope): string { + ++$resolverCalls; + + return "{$scope}:{$identifier}"; + }); + + $repo = $this->stubCache(); + $repo->shouldReceive('forget') + ->once() + ->with(self::DEFAULT_KEY_PREFIX . ':' . User::class . ':before:' . $user->getAuthIdentifier()) + ->andReturnTrue(); + $repo->shouldReceive('forget') + ->once() + ->with('admin_users:' . User::class . ':before:' . $user->getAuthIdentifier()) + ->andReturnTrue(); + + $this->makeCachedProvider(); + + DB::transaction(function () use (&$scope, $user): void { + $this->fireUserEvent('saved', $user); + $scope = 'after'; + + $provider = new EloquentUserProvider($this->app->make('hash'), User::class); + $provider->enableCache(null, prefix: 'admin_users'); + }); + + $this->assertSame(1, $resolverCalls); + } + public function testModelEventInvalidationProvidesProviderModelAndUser(): void { $user = User::query()->firstOrFail(); @@ -508,6 +672,11 @@ protected function makeCachedProvider(): EloquentUserProvider return $provider; } + protected function fireUserEvent(string $event, User $user): void + { + $this->app->make('events')->dispatch("eloquent.{$event}: " . User::class, $user); + } + /** * Create a provider backed by the real serializing cache manager. * diff --git a/tests/Integration/Database/DatabaseTransactionsTest.php b/tests/Integration/Database/DatabaseTransactionsTest.php index 16f5d4288..10bc48af8 100644 --- a/tests/Integration/Database/DatabaseTransactionsTest.php +++ b/tests/Integration/Database/DatabaseTransactionsTest.php @@ -136,7 +136,8 @@ public function testTransactionsDoNotAffectDifferentConnections() try { DB::connection('second_connection')->transaction(function () use ($thirdObject) { - DB::afterCommit(fn () => $thirdObject->handle()); + // Register through the connection that owns this transaction. + DB::connection('second_connection')->afterCommit(fn () => $thirdObject->handle()); throw new Exception; }); @@ -149,6 +150,99 @@ public function testTransactionsDoNotAffectDifferentConnections() $this->assertFalse($thirdObject->ran); } + public function testAfterCommitCallbacksFollowTheirNamedConnection(): void + { + $calls = []; + $default = DB::connection(); + $second = DB::connection('second_connection'); + + $default->beginTransaction(); + $second->beginTransaction(); + + $default->afterCommit(function () use (&$calls): void { + $calls[] = 'default'; + }); + + $second->commit(); + $this->assertSame([], $calls); + + $default->commit(); + $this->assertSame(['default'], $calls); + } + + public function testAfterCommitCallbacksAreDiscardedWithTheirNamedConnectionRollback(): void + { + $calls = []; + $default = DB::connection(); + $second = DB::connection('second_connection'); + + $default->beginTransaction(); + $second->beginTransaction(); + + $default->afterCommit(function () use (&$calls): void { + $calls[] = 'default'; + }); + + $default->rollBack(); + $second->commit(); + + $this->assertSame([], $calls); + } + + public function testAfterRollbackCallbacksFollowTheirNamedConnection(): void + { + $calls = []; + $default = DB::connection(); + $second = DB::connection('second_connection'); + + $default->beginTransaction(); + $second->beginTransaction(); + + $default->afterRollBack(function () use (&$calls): void { + $calls[] = 'default'; + }); + + $second->rollBack(); + $this->assertSame([], $calls); + + $default->rollBack(); + $this->assertSame(['default'], $calls); + } + + public function testAfterCommitRunsImmediatelyWhenOnlyAnotherConnectionHasATransaction(): void + { + $calls = []; + $default = DB::connection(); + $second = DB::connection('second_connection'); + + $second->beginTransaction(); + + $default->afterCommit(function () use (&$calls): void { + $calls[] = 'default'; + }); + + $this->assertSame(['default'], $calls); + + $second->rollBack(); + } + + public function testAfterRollbackDoesNotFollowAnotherConnectionTransaction(): void + { + $calls = []; + $default = DB::connection(); + $second = DB::connection('second_connection'); + + $second->beginTransaction(); + + $default->afterRollBack(function () use (&$calls): void { + $calls[] = 'default'; + }); + + $second->rollBack(); + + $this->assertSame([], $calls); + } + public function testSuffixedConnectionsUseBaseNameForTransactionCallbacks(): void { $afterCommitRan = false; diff --git a/tests/Sanctum/ActingAsTest.php b/tests/Sanctum/ActingAsTest.php index 7759ff54d..8caaf596c 100644 --- a/tests/Sanctum/ActingAsTest.php +++ b/tests/Sanctum/ActingAsTest.php @@ -8,6 +8,7 @@ use Hypervel\Sanctum\Sanctum; use Hypervel\Sanctum\SanctumServiceProvider; use Hypervel\Testbench\TestCase; +use Hypervel\Tests\Sanctum\Fixtures\TokenAbility; use Hypervel\Tests\Sanctum\Fixtures\User; class ActingAsTest extends TestCase @@ -81,6 +82,7 @@ public function testActingAsWithCustomGuard(): void Sanctum::actingAs($user, ['read'], 'api'); $this->assertSame($user, $this->app->make('auth')->guard('api')->user()); + $this->assertSame('api', $this->app->make('auth')->getDefaultDriver()); } public function testActingAsRemovesRecentlyCreatedFlag(): void @@ -92,4 +94,15 @@ public function testActingAsRemovesRecentlyCreatedFlag(): void $this->assertFalse($user->wasRecentlyCreated); } + + public function testActingAsNormalizesBackedEnumAbilities(): void + { + $user = new User; + + Sanctum::actingAs($user, [TokenAbility::PostsRead]); + + $this->assertTrue($user->tokenCan(TokenAbility::PostsRead)); + $this->assertTrue($user->tokenCan('posts:read')); + $this->assertFalse($user->tokenCan(TokenAbility::PostsWrite)); + } } diff --git a/tests/Sanctum/CurrentApplicationUrlWithPortTest.php b/tests/Sanctum/CurrentApplicationUrlWithPortTest.php index eb1116f6b..fb34b2822 100644 --- a/tests/Sanctum/CurrentApplicationUrlWithPortTest.php +++ b/tests/Sanctum/CurrentApplicationUrlWithPortTest.php @@ -35,4 +35,13 @@ public function testCurrentApplicationUrlWhenNotSet(): void $this->assertEquals('', $result); } + + public function testFlushStateRestoresCurrentRequestHostPlaceholder(): void + { + Sanctum::$currentRequestHostPlaceholder = '__CUSTOM_HOST__'; + + Sanctum::flushState(); + + $this->assertSame('__SANCTUM_CURRENT_REQUEST_HOST__', Sanctum::$currentRequestHostPlaceholder); + } } diff --git a/tests/Sanctum/EnsureFrontendRequestsAreStatefulTest.php b/tests/Sanctum/EnsureFrontendRequestsAreStatefulTest.php index 6b824e67a..942f40251 100644 --- a/tests/Sanctum/EnsureFrontendRequestsAreStatefulTest.php +++ b/tests/Sanctum/EnsureFrontendRequestsAreStatefulTest.php @@ -4,10 +4,13 @@ namespace Hypervel\Tests\Sanctum; +use Closure; use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Cookie\Middleware\AddQueuedCookiesToResponse; use Hypervel\Http\Request; use Hypervel\Http\Response; use Hypervel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful; +use Hypervel\Session\Middleware\StartSession; use Hypervel\Testbench\TestCase; use TypeError; @@ -194,4 +197,35 @@ public function testMiddlewareDoesNotMutateSessionConfig(): void $this->assertFalse($this->app->make('config')->get('session.http_only')); $this->assertSame('strict', $this->app->make('config')->get('session.same_site')); } + + public function testFrontendMiddlewareOnlyOmitsMissingEntriesAndDeduplicatesStrictly(): void + { + config([ + 'sanctum.middleware' => [ + 'encrypt_cookies' => StartSession::class, + 'validate_csrf_token' => false, + 'authenticate_session' => null, + ], + ]); + + $middleware = (new EnsureFrontendRequestsAreStatefulFixture)->middleware(); + + $this->assertInstanceOf(Closure::class, $middleware[0]); + $this->assertSame([ + StartSession::class, + AddQueuedCookiesToResponse::class, + false, + ], array_slice($middleware, 1)); + } +} + +class EnsureFrontendRequestsAreStatefulFixture extends EnsureFrontendRequestsAreStateful +{ + /** + * Get the frontend middleware list. + */ + public function middleware(): array + { + return $this->frontendMiddleware(); + } } diff --git a/tests/Sanctum/GuardTest.php b/tests/Sanctum/GuardTest.php index f042aebad..ab8755f96 100644 --- a/tests/Sanctum/GuardTest.php +++ b/tests/Sanctum/GuardTest.php @@ -99,6 +99,14 @@ protected function useCustomTrackingPersonalAccessTokenModel(ApplicationContract ); } + protected function useCustomLookupPersonalAccessTokenModel(ApplicationContract $app): void + { + $app->make('config')->set( + 'sanctum.testing_personal_access_token_model', + CustomLookupPersonalAccessToken::class, + ); + } + /** * Get the migrations to run for the test. */ @@ -165,6 +173,15 @@ protected function defineTestRoutes(): void ]); }); + Route::post('/test/input-token', function () { + $user = auth('sanctum')->user(); + + return response()->json([ + 'authenticated' => $user !== null, + 'user_id' => $user?->id, + ]); + }); + Route::get('/test/last-used-at', function () { $user = auth('sanctum')->user(); $token = $user?->currentAccessToken(); @@ -262,6 +279,64 @@ public function testAuthenticationWithTokenIfNoSessionPresent(): void ]); } + public function testAuthenticationUsesTheRequestBearerParser(): void + { + [$user, $token, $plainToken] = $this->createUserWithToken(); + + $this->withHeaders([ + 'Authorization' => 'Basic ignored, bEaReR ' . $plainToken . ',ignored', + ])->getJson('/test/user') + ->assertOk() + ->assertJson([ + 'authenticated' => true, + 'user_id' => $user->id, + 'token_id' => $token->id, + ]); + } + + public function testAuthenticationDoesNotReadTokensFromTheQueryString(): void + { + [, $token, $plainToken] = $this->createUserWithToken(); + + $this->getJson('/test/user?token=' . urlencode($plainToken)) + ->assertOk() + ->assertJson([ + 'authenticated' => false, + 'user_id' => null, + ]); + + $this->assertNull($token->fresh()->last_used_at); + } + + public function testAuthenticationDoesNotReadTokensFromRequestInput(): void + { + [, $token, $plainToken] = $this->createUserWithToken(); + + $this->postJson('/test/input-token', ['token' => $plainToken]) + ->assertOk() + ->assertJson([ + 'authenticated' => false, + 'user_id' => null, + ]); + + $this->assertNull($token->fresh()->last_used_at); + } + + #[DefineEnvironment('useCustomLookupPersonalAccessTokenModel')] + public function testConfiguredTokenModelOwnsTokenLookupValidation(): void + { + [$user] = $this->createUserWithToken(); + + $this->withHeaders([ + 'Authorization' => 'Bearer custom-token', + ])->getJson('/test/user') + ->assertOk() + ->assertJson([ + 'authenticated' => true, + 'user_id' => $user->id, + ]); + } + public function testHotTokenAuthenticationDoesNotQueryTokenOrUserTables(): void { [$user, $token, $plainToken] = $this->createUserWithToken(); @@ -1044,6 +1119,17 @@ public function updateLastUsedAt(): void } } +class CustomLookupPersonalAccessToken extends PersonalAccessToken +{ + /** + * Find the token instance matching the given token. + */ + public static function findToken(string $token): ?static + { + return $token === 'custom-token' ? static::query()->first() : null; + } +} + class NullUserStatefulGuard implements StatefulGuard { public function attempt(array $credentials = [], bool $remember = false): bool diff --git a/tests/Sanctum/HasApiTokensTest.php b/tests/Sanctum/HasApiTokensTest.php index b9ebfcd96..384357fc8 100644 --- a/tests/Sanctum/HasApiTokensTest.php +++ b/tests/Sanctum/HasApiTokensTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Sanctum; use Hypervel\Sanctum\PersonalAccessToken; +use Hypervel\Sanctum\PersonalAccessTokenRelation; use Hypervel\Sanctum\Sanctum; use Hypervel\Sanctum\TransientToken; use Hypervel\Testbench\TestCase; @@ -97,8 +98,31 @@ public function testCanDetermineWhetherAnAuthenticatableSupportsApiTokens(): voi $this->assertFalse(Sanctum::supportsTokens(new DummyAuthenticatable)); $this->assertFalse(Sanctum::supportsTokens(null)); } + + public function testTokenRelationUsesTheDedicatedFactoryExtensionPoint(): void + { + $user = new UserWithCustomTokenRelation; + + $this->assertInstanceOf(PersonalAccessTokenRelation::class, $user->tokens()); + $this->assertTrue($user->tokenRelationCreated); + } } class SanctumUserChild extends User { } + +class UserWithCustomTokenRelation extends User +{ + public bool $tokenRelationCreated = false; + + /** + * Instantiate the personal access token relationship. + */ + protected function newTokenRelation(): PersonalAccessTokenRelation + { + $this->tokenRelationCreated = true; + + return parent::newTokenRelation(); + } +} diff --git a/tests/Sanctum/PackageMetadataTest.php b/tests/Sanctum/PackageMetadataTest.php new file mode 100644 index 000000000..466775c8b --- /dev/null +++ b/tests/Sanctum/PackageMetadataTest.php @@ -0,0 +1,52 @@ +assertArrayHasKey($dependency, $composer['require']); + $this->assertIsString($composer['require'][$dependency]); + $this->assertNotSame('', trim($composer['require'][$dependency])); + } + + $providers = [SanctumServiceProvider::class]; + + $this->assertSame($providers, $composer['extra']['hypervel']['providers']); + $this->assertContains(SanctumServiceProvider::class, $rootComposer['extra']['hypervel']['providers']); + } +} diff --git a/tests/Sanctum/PersonalAccessTokenCacheTest.php b/tests/Sanctum/PersonalAccessTokenCacheTest.php index 2287fdb27..fd1debd51 100644 --- a/tests/Sanctum/PersonalAccessTokenCacheTest.php +++ b/tests/Sanctum/PersonalAccessTokenCacheTest.php @@ -8,6 +8,8 @@ use Hypervel\Cache\NullSentinel; use Hypervel\Cache\Repository as CacheRepository; use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Database\Eloquent\SoftDeletes; +use Hypervel\Database\Events\QueryExecuted; use Hypervel\Database\Schema\Blueprint; use Hypervel\Foundation\Testing\RefreshDatabase; use Hypervel\Sanctum\PersonalAccessToken; @@ -19,6 +21,8 @@ use Hypervel\Testbench\TestCase; use Hypervel\Tests\Sanctum\Fixtures\TestUser; use Mockery as m; +use RuntimeException; +use UnitEnum; class PersonalAccessTokenCacheTest extends TestCase { @@ -79,6 +83,10 @@ protected function defineEnvironment(ApplicationContract $app): void 'driver' => 'eloquent', 'model' => TestUser::class, ], + 'database.connections.sanctum_secondary' => [ + 'driver' => 'sqlite', + 'database' => ':memory:', + ], 'sanctum.cache.enabled' => $this->tokenCacheEnabled, ]); } @@ -99,6 +107,46 @@ protected function useEagerTokenablePersonalAccessTokenModel(ApplicationContract ); } + protected function useNamespacedPersonalAccessTokenModel(ApplicationContract $app): void + { + $app->make('config')->set( + 'sanctum.testing_personal_access_token_model', + NamespacedPersonalAccessToken::class, + ); + } + + protected function useSoftDeletingPersonalAccessTokenModel(ApplicationContract $app): void + { + $app->make('config')->set( + 'sanctum.testing_personal_access_token_model', + SoftDeletingPersonalAccessToken::class, + ); + } + + protected function useTimestampDisabledPersonalAccessTokenModel(ApplicationContract $app): void + { + $app->make('config')->set( + 'sanctum.testing_personal_access_token_model', + TimestampDisabledPersonalAccessToken::class, + ); + } + + protected function useCustomTimestampPersonalAccessTokenModel(ApplicationContract $app): void + { + $app->make('config')->set( + 'sanctum.testing_personal_access_token_model', + CustomTimestampPersonalAccessToken::class, + ); + } + + protected function useSecondaryConnectionPersonalAccessTokenModel(ApplicationContract $app): void + { + $app->make('config')->set( + 'sanctum.testing_personal_access_token_model', + SecondaryConnectionPersonalAccessToken::class, + ); + } + protected function disableTokenCache(ApplicationContract $app): void { $this->tokenCacheEnabled = false; @@ -162,6 +210,27 @@ public function testEmptyTokenIdDoesNotQueryOrCache(): void $this->assertNull($this->cacheRepository()->getRaw('sanctum:')); } + public function testEmptyPlainTokenDoesNotQueryOrCache(): void + { + DB::enableQueryLog(); + + $this->assertNull(PersonalAccessToken::findToken('1|')); + + $this->assertSame(0, $this->countQueriesForTable('personal_access_tokens')); + $this->assertNull($this->cacheRepository()->getRaw('sanctum:1')); + } + + public function testOverflowingIntegerTokenIdDoesNotQueryOrCache(): void + { + $id = PHP_INT_MAX . '0'; + DB::enableQueryLog(); + + $this->assertNull(PersonalAccessToken::findToken("{$id}|missing")); + + $this->assertSame(0, $this->countQueriesForTable('personal_access_tokens')); + $this->assertNull($this->cacheRepository()->getRaw("sanctum:{$id}")); + } + #[DefineEnvironment('useStringKeyPersonalAccessTokenModel')] public function testStringKeyTokenModelAcceptsNonNumericTokenId(): void { @@ -191,6 +260,32 @@ public function testStringKeyTokenModelAcceptsNonNumericTokenId(): void $this->assertSame(0, $this->countQueriesForTable('string_key_personal_access_tokens')); } + #[DefineEnvironment('useStringKeyPersonalAccessTokenModel')] + public function testStringKeyTokenRelationDeleteInvalidatesTheSelectedToken(): void + { + $this->createStringKeyTokenTable(); + $user = TestUser::create([ + 'name' => 'Test User', + 'email' => 'string-relation@example.com', + 'password' => password_hash('password', PASSWORD_DEFAULT), + ]); + $token = $user->tokens()->forceCreate([ + 'id' => 'token_01', + 'name' => 'Test Token', + 'token' => hash('sha256', 'secret'), + 'abilities' => ['*'], + ]); + $foundToken = StringKeyPersonalAccessToken::findToken('token_01|secret'); + $this->assertInstanceOf(StringKeyPersonalAccessToken::class, $foundToken); + $this->assertInstanceOf(TestUser::class, StringKeyPersonalAccessToken::findTokenable($foundToken)); + + $this->assertSame(1, $user->tokens()->delete()); + + $this->assertDatabaseMissing('string_key_personal_access_tokens', ['id' => $token->id]); + $this->assertNull($this->cacheRepository()->getRaw('sanctum:token_01')); + $this->assertNull($this->cacheRepository()->getRaw('sanctum:token_01:tokenable')); + } + public function testPlainTokenLookupIsNotSupported(): void { $this->createToken(); @@ -224,6 +319,65 @@ public function testValidTokenIsCached(): void $this->assertSame(0, $this->countQueriesForTable('personal_access_tokens')); } + public function testCreatingTokenClearsAPreexistingNegativeEntry(): void + { + $this->cacheRepository()->rememberNullable('sanctum:1', 300, static fn () => null); + + $this->assertSame(NullSentinel::VALUE, $this->cacheRepository()->getRaw('sanctum:1')); + + $token = $this->createToken(); + + $this->assertSame(1, $token->id); + $this->assertNull($this->cacheRepository()->getRaw('sanctum:1')); + $this->assertTrue($token->is(PersonalAccessToken::findToken('1|secret'))); + } + + public function testCreatingTokenInvalidatesNegativeCacheOnlyAfterOuterCommit(): void + { + $this->cacheRepository()->rememberNullable('sanctum:1', 300, static fn () => null); + + DB::transaction(function (): void { + DB::transaction(function (): void { + $this->createToken(); + + $this->assertSame(NullSentinel::VALUE, $this->cacheRepository()->getRaw('sanctum:1')); + }); + + $this->assertSame(NullSentinel::VALUE, $this->cacheRepository()->getRaw('sanctum:1')); + }); + + $this->assertNull($this->cacheRepository()->getRaw('sanctum:1')); + } + + public function testRolledBackTokenCreationKeepsTheCommittedNegativeCacheEntry(): void + { + $this->cacheRepository()->rememberNullable('sanctum:1', 300, static fn () => null); + + try { + DB::transaction(function (): never { + $this->createToken(); + + throw new RuntimeException('rollback'); + }); + } catch (RuntimeException) { + // Ignore the expected rollback exception. + } + + $this->assertSame(NullSentinel::VALUE, $this->cacheRepository()->getRaw('sanctum:1')); + $this->assertDatabaseMissing('personal_access_tokens', ['id' => 1]); + } + + public function testCancelledTokenCreationDoesNotInvalidateNegativeCache(): void + { + $this->cacheRepository()->rememberNullable('sanctum:1', 300, static fn () => null); + PersonalAccessToken::creating(static fn (): false => false); + + $this->createToken(); + + $this->assertSame(NullSentinel::VALUE, $this->cacheRepository()->getRaw('sanctum:1')); + $this->assertDatabaseMissing('personal_access_tokens', ['id' => 1]); + } + #[DefineEnvironment('useEagerTokenablePersonalAccessTokenModel')] public function testCachedTokenNeverRetainsEagerLoadedTokenable(): void { @@ -365,6 +519,85 @@ public function testLastUsedAtAuditWriteForgetsOnlyTokenEntry(): void $this->assertNotNull($this->cacheRepository()->getRaw("sanctum:{$token->id}:tokenable")); } + #[DefineEnvironment('useTimestampDisabledPersonalAccessTokenModel')] + public function testLastUsedAtAuditWriteWithoutModelTimestampsForgetsOnlyTokenEntry(): void + { + $token = $this->createToken(); + $this->assertInstanceOf(TimestampDisabledPersonalAccessToken::class, $token); + $foundToken = TimestampDisabledPersonalAccessToken::findToken($token->id . '|secret'); + $this->assertInstanceOf(TimestampDisabledPersonalAccessToken::class, $foundToken); + $this->assertInstanceOf( + TestUser::class, + TimestampDisabledPersonalAccessToken::findTokenable($foundToken), + ); + + $token->forceFill(['last_used_at' => now()])->save(); + + $this->assertNull($this->cacheRepository()->getRaw("sanctum:{$token->id}")); + $this->assertNotNull($this->cacheRepository()->getRaw("sanctum:{$token->id}:tokenable")); + } + + #[DefineEnvironment('useCustomTimestampPersonalAccessTokenModel')] + public function testLastUsedAtAuditWriteWithCustomUpdatedAtColumnForgetsOnlyTokenEntry(): void + { + $this->app->make('db')->connection()->getSchemaBuilder()->table( + 'personal_access_tokens', + static fn (Blueprint $table) => $table->timestamp('modified_at')->nullable(), + ); + $token = $this->createToken(); + $this->assertInstanceOf(CustomTimestampPersonalAccessToken::class, $token); + $foundToken = CustomTimestampPersonalAccessToken::findToken($token->id . '|secret'); + $this->assertInstanceOf(CustomTimestampPersonalAccessToken::class, $foundToken); + $this->assertInstanceOf(TestUser::class, CustomTimestampPersonalAccessToken::findTokenable($foundToken)); + + $token->forceFill(['last_used_at' => now()])->save(); + + $this->assertNull($this->cacheRepository()->getRaw("sanctum:{$token->id}")); + $this->assertNotNull($this->cacheRepository()->getRaw("sanctum:{$token->id}:tokenable")); + } + + public function testLastUsedAtCacheRefreshRunsAfterTheUpdatedEventForget(): void + { + $token = $this->createToken(); + $this->warmTokenCache($token); + + DB::transaction(function () use ($token): void { + $token->updateLastUsedAt(); + + $cachedToken = $this->cacheRepository()->getRaw("sanctum:{$token->id}"); + $this->assertInstanceOf(PersonalAccessToken::class, $cachedToken); + $this->assertNull($cachedToken->last_used_at); + }); + + $cachedToken = $this->cacheRepository()->getRaw("sanctum:{$token->id}"); + $this->assertInstanceOf(PersonalAccessToken::class, $cachedToken); + $this->assertNotNull($cachedToken->last_used_at); + $this->assertFalse($cachedToken->relationLoaded('tokenable')); + $this->assertNotNull($this->cacheRepository()->getRaw("sanctum:{$token->id}:tokenable")); + } + + public function testRolledBackLastUsedAtUpdateKeepsTheCommittedCacheEntry(): void + { + $token = $this->createToken(); + $this->warmTokenCache($token); + + try { + DB::transaction(function () use ($token): never { + $token->updateLastUsedAt(); + + throw new RuntimeException('rollback'); + }); + } catch (RuntimeException) { + // Ignore the expected rollback exception. + } + + $cachedToken = $this->cacheRepository()->getRaw("sanctum:{$token->id}"); + $this->assertInstanceOf(PersonalAccessToken::class, $cachedToken); + $this->assertNull($cachedToken->last_used_at); + $this->assertNull($token->fresh()->last_used_at); + $this->assertNotNull($this->cacheRepository()->getRaw("sanctum:{$token->id}:tokenable")); + } + public function testSuccessfulLastUsedAtUpdatePreservesModifiedConnectionState(): void { $token = $this->createToken(); @@ -399,7 +632,10 @@ public function testCancelledLastUsedAtUpdateRestoresAttributeWithoutRefreshingC $this->assertTrue($token->last_used_at->equalTo($lastUsedAt)); $this->assertTrue($token->fresh()->last_used_at->equalTo($lastUsedAt)); $this->assertTrue($token->getConnection()->hasModifiedRecords()); - $this->assertNull($this->cacheRepository()->getRaw("sanctum:{$token->id}")); + $this->assertInstanceOf( + PersonalAccessToken::class, + $this->cacheRepository()->getRaw("sanctum:{$token->id}"), + ); } public function testMissingTokenableIsNotCached(): void @@ -537,6 +773,98 @@ public function testUpdatingTokenForgetsTokenAndPositiveTokenableCacheEntries(): $this->assertNull($this->cacheRepository()->getRaw("sanctum:{$token->id}:tokenable")); } + public function testUpdatingTokenInvalidatesCacheOnlyAfterCommit(): void + { + $token = $this->createToken(); + $this->warmTokenCache($token); + + DB::transaction(function () use ($token): void { + $token->forceFill(['name' => 'Updated Token'])->save(); + + $this->assertNotNull($this->cacheRepository()->getRaw("sanctum:{$token->id}")); + $this->assertNotNull($this->cacheRepository()->getRaw("sanctum:{$token->id}:tokenable")); + }); + + $this->assertNull($this->cacheRepository()->getRaw("sanctum:{$token->id}")); + $this->assertNull($this->cacheRepository()->getRaw("sanctum:{$token->id}:tokenable")); + } + + public function testNestedUpdateInvalidationWaitsForTheOuterCommit(): void + { + $token = $this->createToken(); + $this->warmTokenCache($token); + + DB::transaction(function () use ($token): void { + DB::transaction(function () use ($token): void { + $token->forceFill(['name' => 'Updated Token'])->save(); + }); + + $this->assertNotNull($this->cacheRepository()->getRaw("sanctum:{$token->id}")); + }); + + $this->assertNull($this->cacheRepository()->getRaw("sanctum:{$token->id}")); + } + + public function testRolledBackUpdateDoesNotInvalidateCommittedCache(): void + { + $token = $this->createToken(); + $this->warmTokenCache($token); + + try { + DB::transaction(function () use ($token): never { + $token->forceFill(['name' => 'Rolled Back'])->save(); + + throw new RuntimeException('rollback'); + }); + } catch (RuntimeException) { + // Ignore the expected rollback exception. + } + + $this->assertNotNull($this->cacheRepository()->getRaw("sanctum:{$token->id}")); + $this->assertNotNull($this->cacheRepository()->getRaw("sanctum:{$token->id}:tokenable")); + } + + public function testInvalidationRunsImmediatelyWithoutManagerOrTransaction(): void + { + $token = (new EventPersonalAccessToken)->setConnection('sanctum_secondary'); + $token->setRawAttributes(['id' => 999], true); + $this->cacheRepository()->put('sanctum:999', $token, 300); + $this->cacheRepository()->put('sanctum:999:tokenable', 'cached', 300); + $connection = DB::connection('sanctum_secondary'); + $manager = $connection->getTransactionManager(); + $connection->unsetTransactionManager(); + + try { + $token->fireUpdatedEvent(); + } finally { + $connection->setTransactionManager($manager); + } + + $this->assertNull($this->cacheRepository()->getRaw('sanctum:999')); + $this->assertNull($this->cacheRepository()->getRaw('sanctum:999:tokenable')); + } + + public function testInvalidationFailsClosedWithoutManagerDuringTransaction(): void + { + $token = (new EventPersonalAccessToken)->setConnection('sanctum_secondary'); + $token->setRawAttributes(['id' => 999], true); + $this->cacheRepository()->put('sanctum:999', $token, 300); + $connection = DB::connection('sanctum_secondary'); + $manager = $connection->getTransactionManager(); + $connection->unsetTransactionManager(); + $connection->beginTransaction(); + + try { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Transactions Manager has not been set.'); + + $token->fireUpdatedEvent(); + } finally { + $connection->rollBack(); + $connection->setTransactionManager($manager); + } + } + public function testDeletingTokenForgetsTokenAndTokenableCacheEntries(): void { $token = $this->createToken(); @@ -553,6 +881,388 @@ public function testDeletingTokenForgetsTokenAndTokenableCacheEntries(): void $this->assertNull($this->cacheRepository()->getRaw("sanctum:{$token->id}:tokenable")); } + public function testDeletingTokenInvalidatesCacheOnlyAfterCommit(): void + { + $token = $this->createToken(); + $this->warmTokenCache($token); + + DB::transaction(function () use ($token): void { + $token->delete(); + + $this->assertNotNull($this->cacheRepository()->getRaw("sanctum:{$token->id}")); + $this->assertNotNull($this->cacheRepository()->getRaw("sanctum:{$token->id}:tokenable")); + }); + + $this->assertNull($this->cacheRepository()->getRaw("sanctum:{$token->id}")); + $this->assertNull($this->cacheRepository()->getRaw("sanctum:{$token->id}:tokenable")); + } + + public function testNestedDeleteInvalidationWaitsForTheOuterCommit(): void + { + $token = $this->createToken(); + $this->warmTokenCache($token); + + DB::transaction(function () use ($token): void { + DB::transaction(function () use ($token): void { + $token->delete(); + + $this->assertNotNull($this->cacheRepository()->getRaw("sanctum:{$token->id}")); + }); + + $this->assertNotNull($this->cacheRepository()->getRaw("sanctum:{$token->id}")); + }); + + $this->assertNull($this->cacheRepository()->getRaw("sanctum:{$token->id}")); + $this->assertNull($this->cacheRepository()->getRaw("sanctum:{$token->id}:tokenable")); + } + + public function testRolledBackTokenDeleteKeepsTheCommittedCacheEntries(): void + { + $token = $this->createToken(); + $this->warmTokenCache($token); + + try { + DB::transaction(function () use ($token): never { + $token->delete(); + + throw new RuntimeException('rollback'); + }); + } catch (RuntimeException) { + // Ignore the expected rollback exception. + } + + $this->assertDatabaseHas('personal_access_tokens', ['id' => $token->id]); + $this->assertNotNull($this->cacheRepository()->getRaw("sanctum:{$token->id}")); + $this->assertNotNull($this->cacheRepository()->getRaw("sanctum:{$token->id}:tokenable")); + } + + #[DefineEnvironment('useNamespacedPersonalAccessTokenModel')] + public function testCustomTokenModelCacheNamespaceIsUsedForEveryCachePath(): void + { + $this->createNamespacedTokenTable(); + $this->cacheRepository()->rememberNullable('custom-sanctum:1', 300, static fn () => null); + + $token = $this->createToken(); + $this->assertInstanceOf(NamespacedPersonalAccessToken::class, $token); + $this->assertSame(1, $token->getKey()); + $this->assertFalse($token->offsetExists('id')); + $this->assertNull($this->cacheRepository()->getRaw('custom-sanctum:1')); + + $foundToken = NamespacedPersonalAccessToken::findToken('1|secret'); + $this->assertInstanceOf(NamespacedPersonalAccessToken::class, $foundToken); + $this->assertInstanceOf(TestUser::class, NamespacedPersonalAccessToken::findTokenable($foundToken)); + $this->assertNotNull($this->cacheRepository()->getRaw('custom-sanctum:1')); + $this->assertNotNull($this->cacheRepository()->getRaw('custom-sanctum:1:tokenable')); + $this->assertNull($this->cacheRepository()->getRaw('sanctum:1')); + + $foundToken->updateLastUsedAt(); + + $this->assertNotNull($this->cacheRepository()->getRaw('custom-sanctum:1')); + $this->assertNotNull($this->cacheRepository()->getRaw('custom-sanctum:1:tokenable')); + + $token->forceFill(['name' => 'Updated'])->save(); + + $this->assertNull($this->cacheRepository()->getRaw('custom-sanctum:1')); + $this->assertNull($this->cacheRepository()->getRaw('custom-sanctum:1:tokenable')); + + $foundToken = NamespacedPersonalAccessToken::findToken('1|secret'); + $this->assertInstanceOf(NamespacedPersonalAccessToken::class, $foundToken); + $this->assertSame(1, $token->tokenable->tokens()->delete()); + $this->assertNull($this->cacheRepository()->getRaw('custom-sanctum:1')); + } + + #[DefineEnvironment('useSoftDeletingPersonalAccessTokenModel')] + public function testSoftDeleteRestoreAndForceDeleteSettleCacheCorrectly(): void + { + $this->app->make('db')->connection()->getSchemaBuilder()->table( + 'personal_access_tokens', + static fn (Blueprint $table) => $table->softDeletes(), + ); + $token = $this->createToken(); + $this->assertInstanceOf(SoftDeletingPersonalAccessToken::class, $token); + $foundToken = SoftDeletingPersonalAccessToken::findToken($token->id . '|secret'); + $this->assertInstanceOf(SoftDeletingPersonalAccessToken::class, $foundToken); + $this->assertInstanceOf(TestUser::class, SoftDeletingPersonalAccessToken::findTokenable($foundToken)); + + $token->delete(); + + $this->assertNotNull( + SoftDeletingPersonalAccessToken::withTrashed()->findOrFail($token->id)->deleted_at, + ); + $this->assertNull($this->cacheRepository()->getRaw("sanctum:{$token->id}")); + + $this->cacheRepository()->rememberNullable( + "sanctum:{$token->id}", + 300, + static fn () => null, + ); + $token->restore(); + + $this->assertNull($this->cacheRepository()->getRaw("sanctum:{$token->id}")); + $this->assertInstanceOf( + SoftDeletingPersonalAccessToken::class, + SoftDeletingPersonalAccessToken::findToken($token->id . '|secret'), + ); + + $token->forceDelete(); + + $this->assertNull($this->cacheRepository()->getRaw("sanctum:{$token->id}")); + $this->assertDatabaseMissing('personal_access_tokens', ['id' => $token->id]); + } + + #[DefineEnvironment('useSoftDeletingPersonalAccessTokenModel')] + public function testSoftDeletingTokenRelationUsesTheModelDeleteScopeAndInvalidatesCache(): void + { + $this->app->make('db')->connection()->getSchemaBuilder()->table( + 'personal_access_tokens', + static fn (Blueprint $table) => $table->softDeletes(), + ); + $token = $this->createToken(); + $foundToken = SoftDeletingPersonalAccessToken::findToken($token->id . '|secret'); + $this->assertInstanceOf(SoftDeletingPersonalAccessToken::class, $foundToken); + $user = $token->tokenable; + + $this->assertSame(1, $user->tokens()->delete()); + + $this->assertNotNull( + SoftDeletingPersonalAccessToken::withTrashed()->findOrFail($token->id)->deleted_at, + ); + $this->assertNull($this->cacheRepository()->getRaw("sanctum:{$token->id}")); + } + + public function testDeletingTokenRelationInvalidatesEverySelectedToken(): void + { + $first = $this->createToken(); + $user = $first->tokenable; + $second = $user->tokens()->create([ + 'name' => 'Second Token', + 'token' => hash('sha256', 'second-secret'), + 'abilities' => ['*'], + ]); + $this->warmTokenCache($first); + $secondFound = PersonalAccessToken::findToken($second->id . '|second-secret'); + $this->assertInstanceOf(PersonalAccessToken::class, $secondFound); + $this->assertInstanceOf(TestUser::class, PersonalAccessToken::findTokenable($secondFound)); + + $this->assertSame(2, $user->tokens()->delete()); + + $this->assertDatabaseMissing('personal_access_tokens', ['id' => $first->id]); + $this->assertDatabaseMissing('personal_access_tokens', ['id' => $second->id]); + + foreach ([$first->id, $second->id] as $id) { + $this->assertNull($this->cacheRepository()->getRaw("sanctum:{$id}")); + $this->assertNull($this->cacheRepository()->getRaw("sanctum:{$id}:tokenable")); + } + } + + public function testTokenRelationDeletesOnlyTheIdsSelectedBeforeTheDelete(): void + { + $token = $this->createToken(); + $user = $token->tokenable; + $insertedToken = null; + $selected = false; + + DB::listen(function (QueryExecuted $query) use (&$insertedToken, &$selected, $user): void { + if ($selected + || ! str_starts_with(strtolower(ltrim($query->sql)), 'select') + || ! str_contains($query->sql, 'personal_access_tokens')) { + return; + } + + $selected = true; + $insertedToken = $user->tokens()->create([ + 'name' => 'Inserted Token', + 'token' => hash('sha256', 'inserted-secret'), + 'abilities' => ['*'], + ]); + }); + + $this->assertSame(1, $user->tokens()->delete()); + $this->assertInstanceOf(PersonalAccessToken::class, $insertedToken); + $this->assertDatabaseMissing('personal_access_tokens', ['id' => $token->id]); + $this->assertDatabaseHas('personal_access_tokens', ['id' => $insertedToken->id]); + } + + public function testTokenRelationDeleteDoesNotRetainItsInternalIdConstraint(): void + { + $token = $this->createToken(); + $user = $token->tokenable; + $relation = $user->tokens(); + + $this->assertSame(1, $relation->delete()); + + $user->tokens()->create([ + 'name' => 'Replacement Token', + 'token' => hash('sha256', 'replacement-secret'), + 'abilities' => ['*'], + ]); + + $this->assertSame(1, $relation->count()); + } + + public function testConstrainedTokenRelationDeletesAndInvalidatesOnlyTheSelectedSet(): void + { + $first = $this->createToken(); + $user = $first->tokenable; + $second = $user->tokens()->create([ + 'name' => 'Second Token', + 'token' => hash('sha256', 'second-secret'), + 'abilities' => ['*'], + ]); + $this->warmTokenCache($first); + $this->assertInstanceOf( + PersonalAccessToken::class, + PersonalAccessToken::findToken($second->id . '|second-secret'), + ); + + $this->assertSame(1, $user->tokens()->whereKey($first->id)->delete()); + + $this->assertDatabaseMissing('personal_access_tokens', ['id' => $first->id]); + $this->assertDatabaseHas('personal_access_tokens', ['id' => $second->id]); + $this->assertNull($this->cacheRepository()->getRaw("sanctum:{$first->id}")); + $this->assertNotNull($this->cacheRepository()->getRaw("sanctum:{$second->id}")); + } + + public function testZeroMatchTokenRelationDeleteDoesNotInvalidateExistingTokens(): void + { + $token = $this->createToken(); + $this->warmTokenCache($token); + + $this->assertSame(0, $token->tokenable->tokens()->whereKey(-1)->delete()); + + $this->assertDatabaseHas('personal_access_tokens', ['id' => $token->id]); + $this->assertNotNull($this->cacheRepository()->getRaw("sanctum:{$token->id}")); + $this->assertNotNull($this->cacheRepository()->getRaw("sanctum:{$token->id}:tokenable")); + } + + public function testTokenRelationInvalidationWaitsForCommit(): void + { + $token = $this->createToken(); + $user = $token->tokenable; + $this->warmTokenCache($token); + + DB::transaction(function () use ($token, $user): void { + $this->assertSame(1, $user->tokens()->delete()); + $this->assertNotNull($this->cacheRepository()->getRaw("sanctum:{$token->id}")); + }); + + $this->assertNull($this->cacheRepository()->getRaw("sanctum:{$token->id}")); + $this->assertNull($this->cacheRepository()->getRaw("sanctum:{$token->id}:tokenable")); + } + + #[DefineEnvironment('useSecondaryConnectionPersonalAccessTokenModel')] + public function testTokenRelationInvalidationFollowsTheTokenModelConnection(): void + { + $this->createSecondaryConnectionTokenTable(); + $user = TestUser::create([ + 'name' => 'Test User', + 'email' => 'secondary-token@example.com', + 'password' => password_hash('password', PASSWORD_DEFAULT), + ]); + $token = $user->tokens()->create([ + 'name' => 'Test Token', + 'token' => hash('sha256', 'secret'), + 'abilities' => ['*'], + ]); + $this->assertInstanceOf(SecondaryConnectionPersonalAccessToken::class, $token); + $foundToken = SecondaryConnectionPersonalAccessToken::findToken($token->id . '|secret'); + $this->assertInstanceOf(SecondaryConnectionPersonalAccessToken::class, $foundToken); + $this->cacheRepository()->put("sanctum:{$token->id}:tokenable", 'cached', 300); + $connection = DB::connection('sanctum_secondary'); + $connection->beginTransaction(); + + try { + $this->assertSame(1, $user->tokens()->delete()); + $this->assertNotNull($this->cacheRepository()->getRaw("sanctum:{$token->id}")); + + $connection->commit(); + } finally { + if ($connection->transactionLevel() > 0) { + $connection->rollBack(); + } + } + + $this->assertNull($this->cacheRepository()->getRaw("sanctum:{$token->id}")); + $this->assertNull($this->cacheRepository()->getRaw("sanctum:{$token->id}:tokenable")); + } + + #[DefineEnvironment('useSecondaryConnectionPersonalAccessTokenModel')] + public function testTokenRelationInvalidationFailsClosedWithoutManagerDuringTransaction(): void + { + $this->createSecondaryConnectionTokenTable(); + $user = TestUser::create([ + 'name' => 'Test User', + 'email' => 'managerless-relation@example.com', + 'password' => password_hash('password', PASSWORD_DEFAULT), + ]); + $token = $user->tokens()->create([ + 'name' => 'Test Token', + 'token' => hash('sha256', 'secret'), + 'abilities' => ['*'], + ]); + $this->assertInstanceOf(SecondaryConnectionPersonalAccessToken::class, $token); + $foundToken = SecondaryConnectionPersonalAccessToken::findToken($token->id . '|secret'); + $this->assertInstanceOf(SecondaryConnectionPersonalAccessToken::class, $foundToken); + $this->cacheRepository()->put("sanctum:{$token->id}:tokenable", 'cached', 300); + $connection = DB::connection('sanctum_secondary'); + $manager = $connection->getTransactionManager(); + $connection->unsetTransactionManager(); + $connection->beginTransaction(); + + try { + try { + $user->tokens()->delete(); + + $this->fail('Expected fail-closed settlement.'); + } catch (RuntimeException $exception) { + $this->assertSame('Transactions Manager has not been set.', $exception->getMessage()); + } + } finally { + if ($connection->transactionLevel() > 0) { + $connection->rollBack(); + } + + $connection->setTransactionManager($manager); + } + + $this->assertNotNull($this->cacheRepository()->getRaw("sanctum:{$token->id}")); + $this->assertNotNull($this->cacheRepository()->getRaw("sanctum:{$token->id}:tokenable")); + } + + public function testRolledBackTokenRelationDeleteKeepsCommittedCache(): void + { + $token = $this->createToken(); + $user = $token->tokenable; + $this->warmTokenCache($token); + + try { + DB::transaction(function () use ($user): never { + $user->tokens()->delete(); + + throw new RuntimeException('rollback'); + }); + } catch (RuntimeException) { + // Ignore the expected rollback exception. + } + + $this->assertDatabaseHas('personal_access_tokens', ['id' => $token->id]); + $this->assertNotNull($this->cacheRepository()->getRaw("sanctum:{$token->id}")); + $this->assertNotNull($this->cacheRepository()->getRaw("sanctum:{$token->id}:tokenable")); + } + + #[DefineEnvironment('disableTokenCache')] + public function testCacheDisabledTokenRelationDeleteUsesOneQuery(): void + { + $token = $this->createToken(); + $user = $token->tokenable; + DB::flushQueryLog(); + DB::enableQueryLog(); + + $this->assertSame(1, $user->tokens()->delete()); + + $this->assertSame(1, $this->countAllQueriesForTable('personal_access_tokens')); + } + /** * Create the users table for testing. */ @@ -584,6 +1294,43 @@ protected function createStringKeyTokenTable(): void }); } + /** + * Create the custom-primary-key personal access token table for testing. + */ + protected function createNamespacedTokenTable(): void + { + $this->app->make('db')->connection()->getSchemaBuilder()->create('namespaced_personal_access_tokens', function (Blueprint $table): void { + $table->id('token_id'); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Create the personal access token table on the secondary connection. + */ + protected function createSecondaryConnectionTokenTable(): void + { + $this->app->make('db')->connection('sanctum_secondary')->getSchemaBuilder()->create( + 'personal_access_tokens', + static function (Blueprint $table): void { + $table->id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }, + ); + } + /** * Create a user token. */ @@ -602,6 +1349,17 @@ protected function createToken(): PersonalAccessToken ]); } + /** + * Warm the token and tokenable cache entries. + */ + protected function warmTokenCache(PersonalAccessToken $token): void + { + $foundToken = PersonalAccessToken::findToken($token->id . '|secret'); + + $this->assertInstanceOf(PersonalAccessToken::class, $foundToken); + $this->assertInstanceOf(TestUser::class, PersonalAccessToken::findTokenable($foundToken)); + } + /** * Get the cache repository used by Sanctum. */ @@ -625,6 +1383,17 @@ protected function countQueriesForTable(string $table): int && str_contains($query['query'] ?? '', $table) )); } + + /** + * Count all logged queries for a table. + */ + protected function countAllQueriesForTable(string $table): int + { + return count(array_filter( + DB::getQueryLog(), + static fn (array $query): bool => str_contains($query['query'] ?? '', $table), + )); + } } class StringKeyPersonalAccessToken extends PersonalAccessToken @@ -641,6 +1410,52 @@ class EagerTokenablePersonalAccessToken extends PersonalAccessToken protected array $with = ['tokenable']; } +class NamespacedPersonalAccessToken extends PersonalAccessToken +{ + protected ?string $table = 'namespaced_personal_access_tokens'; + + protected string $primaryKey = 'token_id'; + + /** + * Get cache key for token and tokenable. + */ + protected static function getCacheKey(int|string $tokenId): string + { + return "custom-sanctum:{$tokenId}"; + } +} + +class SoftDeletingPersonalAccessToken extends PersonalAccessToken +{ + use SoftDeletes; +} + +class TimestampDisabledPersonalAccessToken extends PersonalAccessToken +{ + public bool $timestamps = false; +} + +class CustomTimestampPersonalAccessToken extends PersonalAccessToken +{ + public const UPDATED_AT = 'modified_at'; +} + +class SecondaryConnectionPersonalAccessToken extends PersonalAccessToken +{ + protected UnitEnum|string|null $connection = 'sanctum_secondary'; +} + +class EventPersonalAccessToken extends PersonalAccessToken +{ + /** + * Fire the updated event for transaction-settlement tests. + */ + public function fireUpdatedEvent(): void + { + $this->fireModelEvent('updated', false); + } +} + class PersonalAccessTokenCacheTestServiceProvider extends ServiceProvider { /** diff --git a/tests/Sanctum/PersonalAccessTokenTest.php b/tests/Sanctum/PersonalAccessTokenTest.php index ef84dd921..7e083f39d 100644 --- a/tests/Sanctum/PersonalAccessTokenTest.php +++ b/tests/Sanctum/PersonalAccessTokenTest.php @@ -10,7 +10,7 @@ class PersonalAccessTokenTest extends TestCase { - public function testCanDetermineWhatItCanAndCantDo() + public function testCanDetermineWhatItCanAndCantDo(): void { $token = new PersonalAccessToken; @@ -31,7 +31,7 @@ public function testCanDetermineWhatItCanAndCantDo() $this->assertTrue($token->can('bar')); } - public function testCanCheckAbilitiesWithBackedEnum() + public function testCanCheckAbilitiesWithBackedEnum(): void { $token = new PersonalAccessToken; $token->abilities = ['posts:read', 'posts:write']; @@ -41,7 +41,7 @@ public function testCanCheckAbilitiesWithBackedEnum() $this->assertFalse($token->can(TokenAbility::UsersRead)); } - public function testCantCheckAbilitiesWithBackedEnum() + public function testCantCheckAbilitiesWithBackedEnum(): void { $token = new PersonalAccessToken; $token->abilities = ['posts:read']; @@ -50,7 +50,7 @@ public function testCantCheckAbilitiesWithBackedEnum() $this->assertTrue($token->cant(TokenAbility::PostsWrite)); } - public function testWildcardAbilityWorksWithBackedEnum() + public function testWildcardAbilityWorksWithBackedEnum(): void { $token = new PersonalAccessToken; $token->abilities = ['*']; @@ -60,7 +60,7 @@ public function testWildcardAbilityWorksWithBackedEnum() $this->assertTrue($token->can(TokenAbility::UsersRead)); } - public function testMixedStringAndEnumAbilitiesWork() + public function testMixedStringAndEnumAbilitiesWork(): void { $token = new PersonalAccessToken; $token->abilities = ['posts:read', 'legacy-ability']; @@ -72,4 +72,13 @@ public function testMixedStringAndEnumAbilitiesWork() // String check for legacy $this->assertTrue($token->can('legacy-ability')); } + + public function testAbilityChecksDoNotCoerceStoredValues(): void + { + $token = new PersonalAccessToken; + $token->abilities = [1, true]; + + $this->assertFalse($token->can('1')); + $this->assertFalse($token->can('*')); + } } diff --git a/tests/Sanctum/PruneExpiredTest.php b/tests/Sanctum/PruneExpiredTest.php index fc16fd220..5cd021b0c 100644 --- a/tests/Sanctum/PruneExpiredTest.php +++ b/tests/Sanctum/PruneExpiredTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Sanctum; +use Hypervel\Console\Command; use Hypervel\Contracts\Console\Kernel; use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Foundation\Testing\RefreshDatabase; @@ -11,7 +12,9 @@ use Hypervel\Sanctum\PersonalAccessToken; use Hypervel\Sanctum\SanctumServiceProvider; use Hypervel\Support\CarbonImmutable; +use Hypervel\Support\Facades\DB; use Hypervel\Testbench\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; class PruneExpiredTest extends TestCase { @@ -137,4 +140,42 @@ public function testCanDeleteExpiredTokensWithExpiresAtExpiration(): void $this->assertDatabaseHas('personal_access_tokens', ['name' => 'Test_2']); $this->assertDatabaseHas('personal_access_tokens', ['name' => 'Test_3']); } + + #[DataProvider('invalidHoursProvider')] + public function testInvalidHoursFailBeforeQuerying(string $hours): void + { + DB::flushQueryLog(); + DB::enableQueryLog(); + + $this->artisan("sanctum:prune-expired --hours={$hours}") + ->expectsOutput('The --hours option must be a non-negative integer.') + ->assertExitCode(Command::FAILURE); + + $this->assertSame([], DB::getQueryLog()); + } + + public static function invalidHoursProvider(): iterable + { + yield 'negative' => ['-1']; + yield 'decimal' => ['1.5']; + yield 'nonnumeric' => ['invalid']; + } + + public function testZeroHoursIsAccepted(): void + { + $this->app->make('config')->set(['sanctum.expiration' => null]); + + PersonalAccessToken::forceCreate([ + 'tokenable_type' => 'App\Models\User', + 'tokenable_id' => 1, + 'name' => 'Expired Now', + 'token' => hash('sha256', 'expired-now'), + 'expires_at' => CarbonImmutable::now()->subSecond(), + ]); + + $this->artisan('sanctum:prune-expired --hours=0') + ->assertExitCode(Command::SUCCESS); + + $this->assertDatabaseMissing('personal_access_tokens', ['name' => 'Expired Now']); + } } diff --git a/tests/Sanctum/SanctumRoutesTest.php b/tests/Sanctum/SanctumRoutesTest.php new file mode 100644 index 000000000..a0225d489 --- /dev/null +++ b/tests/Sanctum/SanctumRoutesTest.php @@ -0,0 +1,56 @@ +make('config')->set('sanctum.prefix', 'auth'); + } + + protected function disableRoutes(ApplicationContract $app): void + { + $app->make('config')->set('sanctum.routes', false); + } + + public function testCsrfCookieRouteUsesTheExpectedNameUriAndMiddleware(): void + { + $route = Route::getRoutes()->getByName('sanctum.csrf-cookie'); + + $this->assertNotNull($route); + $this->assertSame('sanctum/csrf-cookie', $route->uri); + $this->assertSame(['web'], $route->middleware()); + $this->assertContains('GET', $route->methods()); + } + + #[DefineEnvironment('useCustomPrefix')] + public function testCsrfCookieRouteUsesTheConfiguredPrefix(): void + { + $route = Route::getRoutes()->getByName('sanctum.csrf-cookie'); + + $this->assertNotNull($route); + $this->assertSame('auth/csrf-cookie', $route->uri); + } + + #[DefineEnvironment('disableRoutes')] + public function testCsrfCookieRouteCanBeDisabled(): void + { + $this->assertNull(Route::getRoutes()->getByName('sanctum.csrf-cookie')); + } +} diff --git a/tests/Sanctum/SanctumServiceProviderTest.php b/tests/Sanctum/SanctumServiceProviderTest.php index 66ab4a774..b835faa45 100644 --- a/tests/Sanctum/SanctumServiceProviderTest.php +++ b/tests/Sanctum/SanctumServiceProviderTest.php @@ -5,11 +5,14 @@ namespace Hypervel\Tests\Sanctum; use Closure; +use Hypervel\Auth\AuthManager; use Hypervel\Cache\CacheManager; use Hypervel\Cache\ModelCacheStoreValidator; use Hypervel\Config\Repository as ConfigRepository; +use Hypervel\Contracts\Auth\UserProvider; use Hypervel\Contracts\Cache\Repository as CacheRepository; use Hypervel\Contracts\Config\Repository as ConfigRepositoryContract; +use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Foundation\Application; use Hypervel\Core\Events\AfterWorkerStart; @@ -19,6 +22,7 @@ use Hypervel\Foundation\Auth\User; use Hypervel\Sanctum\PersonalAccessToken; use Hypervel\Sanctum\Sanctum; +use Hypervel\Sanctum\SanctumGuard; use Hypervel\Sanctum\SanctumServiceProvider; use Hypervel\Tests\TestCase; use InvalidArgumentException; @@ -121,9 +125,9 @@ public function testConsoleStartupValidatesTheConfiguredStoreUsingCapturedDepend $provider->boot(); $this->assertTrue($provider->bootCalled); - $this->assertTrue($provider->sanctumGuardRegistered); - $this->assertTrue($provider->sessionCookiesConfigured); - $this->assertTrue($provider->routesRegistered); + $this->assertTrue($provider->routesDefined); + $this->assertTrue($provider->guardConfigured); + $this->assertTrue($provider->middlewareConfigured); $this->assertTrue($provider->publishingRegistered); $this->assertTrue($provider->commandsRegistered); $this->assertInstanceOf(Closure::class, $bootedCallback); @@ -268,6 +272,120 @@ public function testSelectedProviderRequiresAnEloquentAuthenticatableModel(): vo $resolver(); } + public function testDefineRoutesSkipsRegistrationWhenRoutesAreCached(): void + { + $application = m::mock(Application::class); + $application->shouldReceive('routesAreCached')->once()->andReturnTrue(); + + (new SanctumServiceProviderFixture($application))->defineRoutesUsingParent(); + + $this->addToAssertionCount(1); + } + + #[DataProvider('invalidRouteConfigurationProvider')] + public function testDefineRoutesRequiresExactConfigurationTypes( + string $key, + mixed $value, + string $message, + ): void { + $application = m::mock(Application::class); + $application->shouldReceive('routesAreCached')->once()->andReturnFalse(); + $application->shouldReceive('make') + ->once() + ->with(ConfigRepositoryContract::class) + ->andReturn(new ConfigRepository([$key => $value])); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + (new SanctumServiceProviderFixture($application))->defineRoutesUsingParent(); + } + + /** + * Provide invalid Sanctum route configuration. + */ + public static function invalidRouteConfigurationProvider(): array + { + return [ + 'routes must be a boolean' => [ + 'sanctum.routes', + 'true', + 'Configuration value for key [sanctum.routes] must be a boolean, string given.', + ], + 'prefix must be a string' => [ + 'sanctum.prefix', + false, + 'Configuration value for key [sanctum.prefix] must be a string, boolean given.', + ], + ]; + } + + // REMOVED: Hypervel's Middleware::statefulApi() owns middleware priority before kernel construction. + + public function testCreateGuardRemainsAProtectedExtensionPoint(): void + { + $application = m::mock(Application::class); + $container = m::mock(Container::class); + $config = new ConfigRepository([ + 'sanctum' => [ + 'expiration' => null, + 'last_used_at' => true, + ], + ]); + $container->shouldReceive('bound')->once()->with('events')->andReturnFalse(); + $container->shouldReceive('make')->twice()->with('config')->andReturn($config); + $userProvider = m::mock(UserProvider::class); + $authManager = m::mock(AuthManager::class); + $authManager->shouldReceive('createUserProvider')->once()->with('users')->andReturn($userProvider); + + $guard = (new SanctumServiceProviderFixture($application))->createGuardUsingParent( + $authManager, + $container, + 'sanctum', + [ + 'provider' => 'users', + 'session_guards' => ['web'], + ], + ); + + $this->assertInstanceOf(SanctumGuard::class, $guard); + } + + public function testConfiguredGuardResolutionUsesTheProtectedFactoryExtensionPoint(): void + { + $afterResolving = null; + $application = m::mock(Application::class); + $application->shouldReceive('afterResolving') + ->once() + ->with(AuthManager::class, m::on(function (mixed $callback) use (&$afterResolving): bool { + $afterResolving = $callback; + + return $callback instanceof Closure; + })); + $application->shouldReceive('resolved')->once()->with(AuthManager::class)->andReturnFalse(); + + $provider = new ResolvingSanctumServiceProviderFixture($application); + $provider->guard = m::mock(SanctumGuard::class); + $provider->configureGuardUsingParent(); + + $this->assertInstanceOf(Closure::class, $afterResolving); + + $container = m::mock(Container::class); + $container->shouldReceive('make')->once()->with('config')->andReturn(new ConfigRepository([ + 'auth' => [ + 'guards' => [ + 'sanctum' => ['driver' => 'sanctum'], + ], + ], + ])); + $authManager = new AuthManager($container); + + $afterResolving($authManager); + + $this->assertSame($provider->guard, $authManager->guard('sanctum')); + $this->assertTrue($provider->createGuardCalled); + } + public function testConfiguredStoreMustBeAStringOrNull(): void { $config = new ConfigRepository([ @@ -473,11 +591,11 @@ class SanctumServiceProviderFixture extends SanctumServiceProvider { public bool $bootCalled = false; - public bool $sanctumGuardRegistered = false; + public bool $routesDefined = false; - public bool $sessionCookiesConfigured = false; + public bool $guardConfigured = false; - public bool $routesRegistered = false; + public bool $middlewareConfigured = false; public bool $publishingRegistered = false; @@ -494,27 +612,47 @@ public function boot(): void } /** - * Register the Sanctum authentication guard. + * Define the Sanctum routes. + */ + protected function defineRoutes(): void + { + $this->routesDefined = true; + } + + /** + * Invoke the parent route definition. */ - protected function registerSanctumGuard(): void + public function defineRoutesUsingParent(): void { - $this->sanctumGuardRegistered = true; + parent::defineRoutes(); } /** - * Configure session cookies for stateful frontend requests. + * Configure the Sanctum authentication guard. */ - protected function configureSessionCookies(): void + protected function configureGuard(): void { - $this->sessionCookiesConfigured = true; + $this->guardConfigured = true; } /** - * Register the package routes. + * Configure Sanctum's middleware behavior. */ - protected function registerRoutes(): void + protected function configureMiddleware(): void { - $this->routesRegistered = true; + $this->middlewareConfigured = true; + } + + /** + * Invoke the parent guard factory. + */ + public function createGuardUsingParent( + AuthManager $authManager, + Container $app, + string $name, + array $config, + ): SanctumGuard { + return parent::createGuard($authManager, $app, $name, $config); } /** @@ -534,6 +672,35 @@ protected function registerCommands(): void } } +class ResolvingSanctumServiceProviderFixture extends SanctumServiceProvider +{ + public SanctumGuard $guard; + + public bool $createGuardCalled = false; + + /** + * Invoke the parent guard configuration. + */ + public function configureGuardUsingParent(): void + { + parent::configureGuard(); + } + + /** + * Create the test guard instance. + */ + protected function createGuard( + AuthManager $authManager, + Container $app, + string $name, + array $config, + ): SanctumGuard { + $this->createGuardCalled = true; + + return $this->guard; + } +} + class SanctumProviderUser extends User { } diff --git a/types/Sanctum/Sanctum.php b/types/Sanctum/Sanctum.php new file mode 100644 index 000000000..26f8253cd --- /dev/null +++ b/types/Sanctum/Sanctum.php @@ -0,0 +1,53 @@ +', Sanctum::personalAccessTokenModel()); +assertType( + 'Hypervel\Sanctum\PersonalAccessTokenRelation', + (new SanctumTraitOnlyModel)->tokens(), +); +assertType( + 'Hypervel\Sanctum\PersonalAccessToken|null', + (new SanctumTraitOnlyModel)->currentAccessToken(), +); +assertType( + 'Hypervel\Sanctum\NewAccessToken', + (new SanctumTraitOnlyModel)->createToken('example', [SanctumAbility::Read]), +);