diff --git a/AGENTS.md b/AGENTS.md index 385e278573..35d65b8725 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -729,7 +729,7 @@ Each integration group has its own workflow file in `.github/workflows/`: |----------|------|-----------| | `engine.yml` | HTTP test servers | `tests/Integration/Engine`, `tests/Integration/HttpServer` | | `databases.yml` | MySQL, MariaDB, PostgreSQL, SQLite | `tests/Integration/Database`, `tests/Integration/*/Database/*` | -| `redis.yml` | Redis, Redis Cluster, Valkey | `tests/Integration/Auth/Redis`, `tests/Integration/Broadcasting/Redis`, `tests/Integration/Cache/Redis`, `tests/Integration/Horizon`, `tests/Integration/Http/Redis`, `tests/Integration/Queue/Redis`, `tests/Integration/RateLimiter/Redis`, `tests/Integration/Redis`, `tests/Integration/Session/Redis`; it also reruns the driver-neutral queue chaining and dispatching tests with Redis, the listed topology-neutral Reverb state tests with Cluster, and Reverb state recovery with Valkey | +| `redis.yml` | Redis, Redis Cluster, Valkey | `tests/Integration/Auth/Redis`, `tests/Integration/Broadcasting/Redis`, `tests/Integration/Cache`, `tests/Integration/Horizon`, `tests/Integration/Http/Redis`, `tests/Integration/OpenTelemetry/Redis`, `tests/Integration/Queue`, `tests/Integration/RateLimiter/Redis`, `tests/Integration/Redis`, `tests/Integration/Session/Redis`; Cache and Queue run with Redis selected, and it also reruns the listed topology-neutral Reverb state tests with Cluster and Reverb state recovery with Valkey | | `reverb.yml` | Redis-backed Reverb servers and state | `tests/Integration/Reverb` | | `scout.yml` | Meilisearch, Typesense | `tests/Integration/Scout/*` | @@ -929,7 +929,7 @@ Update upstream test imports to point at the new Fixtures namespace. Tests for these features should be **removed** (not commented out) without asking — they will never be supported: - **Databases:** SQL Server, MongoDB, DynamoDB — Hypervel only supports MySQL, MariaDB, PostgreSQL, and SQLite -- **Cache drivers:** Memcached, DynamoDB, MongoDB +- **Cache drivers:** Memcached, APC / APCu, DynamoDB, MongoDB - **Dynamic connections:** `DB::build()`, `DB::connectUsing()` — incompatible with Swoole connection pooling - **Container access:** ArrayAccess and dynamic service properties diff --git a/composer.json b/composer.json index bd4d243c3b..b7301c30b3 100644 --- a/composer.json +++ b/composer.json @@ -87,7 +87,7 @@ "psr/log": "^3.0", "psr/simple-cache": "^3.0", "psy/psysh": "dev-main", - "sentry/sentry": "dev-master", + "sentry/sentry": "^4.32", "spomky-labs/otphp": "^11.0", "symfony/console": "^8.1.2", "symfony/dom-crawler": "^8.1", diff --git a/docs/upstream-sync/sync.yaml b/docs/upstream-sync/sync.yaml index d2be4197db..740f625486 100644 --- a/docs/upstream-sync/sync.yaml +++ b/docs/upstream-sync/sync.yaml @@ -54,15 +54,15 @@ laravel/facade-documenter: laravel/fortify: branch: 1.x - checked_through: null - last_reviewed_pr: null - sync_date: null + checked_through: c456642584c102f6a6aafee9932299542da637b5 + last_reviewed_pr: 703 + sync_date: '2026-10-02' laravel/passkeys-server: branch: main - checked_through: null - last_reviewed_pr: null - sync_date: null + checked_through: 4f81dfd5f7f983bdfe3ee6b3971c51acaa7844c3 + last_reviewed_pr: 40 + sync_date: '2026-10-02' notes: Composer package is laravel/passkeys. laravel/sanctum: diff --git a/phpstan.neon.dist b/phpstan.neon.dist index efdf27f77d..6ef9643941 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -68,12 +68,6 @@ parameters: - '#Call to an undefined method TModel of Hypervel\\Database\\Eloquent\\Model::#' - '#Call to an undefined method TRelatedModel of Hypervel\\Database\\Eloquent\\Model::#' - # Deep generic template limitations - PHPStan can't fully resolve complex generic type relationships - - identifier: generics.lessTypes - path: src/database/* - - identifier: generics.notSubtype - path: src/database/* - # Covariant template types in invariant/contravariant positions - Laravel uses @template-covariant # for semantic documentation on collection-like classes even though it violates strict variance rules. # The code is functionally correct; this is a PHPStan limitation with PHP's lack of runtime generics. diff --git a/src/context/README.md b/src/context/README.md index 8e7b6781ee..0ced384745 100644 --- a/src/context/README.md +++ b/src/context/README.md @@ -2,5 +2,3 @@ Context for Hypervel === [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/context) - -Ported from: https://github.com/hyperf/context diff --git a/src/contracts/README.md b/src/contracts/README.md index 986bf8a71e..6b7a91c6f2 100644 --- a/src/contracts/README.md +++ b/src/contracts/README.md @@ -3,7 +3,4 @@ Contracts for Hypervel [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/contracts) -Ported from: - -- https://github.com/laravel/framework/tree/13.x/src/Illuminate/Contracts -- https://github.com/hyperf/contract +Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Contracts diff --git a/src/contracts/src/Console/Kernel.php b/src/contracts/src/Console/Kernel.php index 7edad6d989..af641bddf6 100644 --- a/src/contracts/src/Console/Kernel.php +++ b/src/contracts/src/Console/Kernel.php @@ -25,31 +25,16 @@ public function handle(InputInterface $input, ?OutputInterface $output = null): */ public function bootstrap(): void; - /** - * Define the application's command schedule. - */ - public function schedule(Schedule $schedule): void; - /** * Resolve a console schedule instance. */ public function resolveConsoleSchedule(): Schedule; - /** - * Register the commands for the application. - */ - public function commands(): void; - /** * Register a Closure based command with the application. */ public function command(string $signature, Closure $callback): ClosureCommand; - /** - * Add loadPaths in the given directory. - */ - public function load(array|string $paths): void; - /** * Set the Artisan commands provided by the application. * diff --git a/src/coordinator/README.md b/src/coordinator/README.md index 30647372bd..02da672042 100644 --- a/src/coordinator/README.md +++ b/src/coordinator/README.md @@ -2,5 +2,3 @@ Coordinator for Hypervel === [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/coordinator) - -Ported from: https://github.com/hyperf/hyperf diff --git a/src/core/README.md b/src/core/README.md index 4c757f1df5..8ebe2d02bd 100644 --- a/src/core/README.md +++ b/src/core/README.md @@ -2,5 +2,3 @@ Framework for Hypervel === [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/framework) - -Ported from: https://github.com/hyperf/hyperf/tree/master/src/framework diff --git a/src/coroutine/README.md b/src/coroutine/README.md index 3f3f46ee21..fbf10c379d 100644 --- a/src/coroutine/README.md +++ b/src/coroutine/README.md @@ -2,5 +2,3 @@ Coroutine for Hypervel === [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/coroutine) - -Ported from: https://github.com/hyperf/coroutine diff --git a/src/database/src/Eloquent/Concerns/HasRelationships.php b/src/database/src/Eloquent/Concerns/HasRelationships.php index aa9a62558b..edaf2e25b3 100644 --- a/src/database/src/Eloquent/Concerns/HasRelationships.php +++ b/src/database/src/Eloquent/Concerns/HasRelationships.php @@ -465,7 +465,7 @@ protected function guessBelongsToRelation(): string * @param HasMany|HasOne|string $relationship * @return ( * $relationship is string - * ? PendingHasThroughRelationship + * ? PendingHasThroughRelationship> * : ( * $relationship is HasMany * ? PendingHasThroughRelationship> diff --git a/src/database/src/Eloquent/PendingHasThroughRelationship.php b/src/database/src/Eloquent/PendingHasThroughRelationship.php index fddb19dc6a..3985ba4eb9 100644 --- a/src/database/src/Eloquent/PendingHasThroughRelationship.php +++ b/src/database/src/Eloquent/PendingHasThroughRelationship.php @@ -17,7 +17,7 @@ /** * @template TIntermediateModel of Model * @template TDeclaringModel of Model - * @template TLocalRelationship of HasOneOrMany + * @template TLocalRelationship of HasOneOrMany */ class PendingHasThroughRelationship { @@ -52,7 +52,7 @@ public function __construct(Model $rootModel, HasOneOrMany $localRelationship) * * @template TRelatedModel of Model * - * @param (callable(TIntermediateModel): (HasMany|HasOne|MorphOneOrMany))|string $callback + * @param (callable(TIntermediateModel): (HasMany|HasOne|MorphOneOrMany))|string $callback * @return ( * $callback is string * ? HasManyThrough|HasOneThrough diff --git a/src/database/src/Query/Grammars/Grammar.php b/src/database/src/Query/Grammars/Grammar.php index d18f205888..7ec56bed49 100755 --- a/src/database/src/Query/Grammars/Grammar.php +++ b/src/database/src/Query/Grammars/Grammar.php @@ -501,6 +501,13 @@ protected function dateBasedWhere(string $type, Builder $query, array $where): s */ protected function whereColumn(Builder $query, array $where): string { + if ($where['operator'] === '<=>') { + $where['column'] = $where['first']; + $where['value'] = new QueryExpression($this->wrap($where['second'])); + + return $this->whereNullSafeEquals($query, $where); + } + $operator = str_replace('?', '??', $where['operator']); return $this->wrap($where['first']) . ' ' . $operator . ' ' . $this->wrap($where['second']); diff --git a/src/docs/fortify.md b/src/docs/fortify.md index 71e452ebb4..60bb27cba0 100644 --- a/src/docs/fortify.md +++ b/src/docs/fortify.md @@ -437,6 +437,18 @@ The built-in two-factor routes include: Fortify stores recovery codes as one encrypted JSON value. When a recovery code is used, Hypervel Fortify replaces the exact decoded JSON array entry and re-encrypts the whole JSON value. +You may customize how recovery codes are generated by registering a callback in the `boot` method of your application's `App\Providers\FortifyServiceProvider` class. Fortify invokes the callback once for each recovery code it generates: + +```php +use Hypervel\Fortify\Fortify; +use Hypervel\Support\Str; + +public function boot(): void +{ + Fortify::generateRecoveryCodesUsing(fn (): string => Str::upper(Str::random(16))); +} +``` + The two-factor provider defaults to 32-character TOTP secrets. The optional `window` feature option is step-based: a value of `1` accepts the previous, current, and next 30-second periods. Accepted TOTP codes are cached for the full accepted window to prevent replay for as long as Fortify still accepts the code. Hypervel Fortify uses fresh OTPHP TOTP objects with an injected clock, so verification does not mutate shared TOTP engine state in a Swoole worker. During login, users with enabled two-factor authentication are redirected to the two-factor challenge route. JSON login requests receive a response containing a `two_factor` boolean. The challenge form should submit either a `code` field containing a TOTP code or a `recovery_code` field containing one of the user's recovery codes to `POST /two-factor-challenge`. @@ -500,6 +512,8 @@ The resolved relying party ID must be a registrable-domain suffix of the resolve `user_handle_secret` is a long-lived, nonempty secret used to derive stable WebAuthn user handles. It defaults to the app key for convenience, but production applications should set a dedicated value before registering passkeys. Changing it changes generated user handles. +Each user handle is derived from the owner's morph type, table, and primary key. If one relying party serves several tenant databases whose owners can share the same type and key, override `getPasskeyUserHandle()` on your passkey user model to include a stable tenant identifier that does not reveal personal information. + ### Frontend Package @@ -509,6 +523,15 @@ Hypervel's passkey routes are compatible with the `@laravel/passkeys` frontend p npm install @laravel/passkeys ``` +Then, you may initiate passkey registration and verification from your frontend: + +```js +import { Passkeys } from '@laravel/passkeys'; + +await Passkeys.register({ name: 'MacBook Pro' }); +await Passkeys.verify(); +``` + The frontend package defaults to these backend endpoints:
@@ -520,7 +543,7 @@ The frontend package defaults to these backend endpoints:
-It also supports route overrides: +If your application uses custom passkey endpoint URIs, you may override the routes on a per-call basis: ```js await Passkeys.verify({ @@ -529,8 +552,18 @@ await Passkeys.verify({ submit: '/passkeys/confirm', }, }); + +await Passkeys.register({ + name: 'MacBook Pro', + routes: { + options: '/user/passkeys/options', + submit: '/user/passkeys', + }, +}); ``` +The package also provides React, Vue, and Svelte helpers via `@laravel/passkeys/react`, `@laravel/passkeys/vue`, and `@laravel/passkeys/svelte`. + ### Request And Response Contracts @@ -548,6 +581,10 @@ Custom passkey frontends should use JSON requests and preserve the normal Hyperv +Standard requests receive redirects instead. Login and confirmation redirect to the intended destination, while registration and deletion redirect back with a `passkey-registered` or `passkey-deleted` status in the session. + +A successful passkey confirmation marks the session as password confirmed for the current guard, so it satisfies that guard's `password.confirm` middleware. + ### Customizing Passkeys @@ -742,7 +779,7 @@ Standalone routes use the following configuration options: | `guard` | The guard selected for standalone routes. An omitted or null value uses the current request guard. | | `middleware` | The required middleware applied to every standalone route. | | `management_middleware` | The required additional middleware applied when creating or deleting passkeys. | -| `throttle` | The throttle middleware applied to passkey login, confirmation, registration, and deletion endpoints. Omission uses `throttle:6,1`; null disables throttling. | +| `throttle` | The throttle middleware applied to passkey login, confirmation, and registration endpoints. Omission uses `throttle:6,1`; null disables throttling. | | `redirect` | The successful login destination used when no intended URL exists. Omission uses `/`. | Call `Passkeys::ignoreRoutes()` during boot before registering your own endpoints: @@ -775,6 +812,7 @@ Call these only during application boot or tests: - `Fortify::updateUserProfileInformationUsing()` - `Fortify::updateUserPasswordsUsing()` - `Fortify::resetUserPasswordsUsing()` +- `Fortify::generateRecoveryCodesUsing()` - `Fortify::redirectUserForTwoFactorAuthenticationUsing()` - `Features::twoFactorAuthentication($options)` - `Features::passkeys($options)` diff --git a/src/docs/migrations.md b/src/docs/migrations.md index 5ad5027650..55e1ba920a 100644 --- a/src/docs/migrations.md +++ b/src/docs/migrations.md @@ -248,6 +248,14 @@ Some migration operations are destructive, which means they may cause you to los php artisan migrate --force ``` +To prevent destructive database commands from running in production at all, even with `--force`, call the `DB` facade's `prohibitDestructiveCommands` method from the `boot` method of your application's `AppServiceProvider`. This prohibits the `db:wipe`, `migrate:fresh`, `migrate:refresh`, `migrate:reset`, and `migrate:rollback` commands: + +```php +use Hypervel\Support\Facades\DB; + +DB::prohibitDestructiveCommands($this->app->isProduction()); +``` + ### Rolling Back Migrations diff --git a/src/docs/queries.md b/src/docs/queries.md index e4f290d9fd..a766a02b6a 100644 --- a/src/docs/queries.md +++ b/src/docs/queries.md @@ -1206,6 +1206,14 @@ $users = DB::table('users') ->get(); ``` +The `<=>` operator compares two columns while treating two `NULL` values as equal, on every supported database: + +```php +$users = DB::table('users') + ->whereColumn('billing_email', '<=>', 'email') + ->get(); +``` + You may also pass an array of column comparisons to the `whereColumn` method. These conditions will be joined using the `and` operator: ```php diff --git a/src/docs/requests.md b/src/docs/requests.md index 010c0bce5d..92104643f0 100644 --- a/src/docs/requests.md +++ b/src/docs/requests.md @@ -417,12 +417,21 @@ $name = $request->string('name')->trim(); #### Retrieving Integer Input Values -To retrieve input values as integers, you may use the `integer` method. This method casts the input value to an integer. If the input is not present, it will return the default value you specify. This is particularly useful for pagination or other numeric inputs: +To retrieve input values as integers, you may use the `integer` method. This method casts the input value to an integer. If the input is not present, it will return the default value you specify, or `0` if you don't specify one. This is particularly useful for pagination or other numeric inputs: ```php $perPage = $request->integer('per_page'); ``` + +#### Retrieving Float Input Values + +Similarly, the `float` method retrieves an input value as a float. If the input is not present, it will return the default value you specify, or `0.0` if you don't specify one: + +```php +$price = $request->float('price'); +``` + #### Retrieving Clamped Input Values diff --git a/src/engine/README.md b/src/engine/README.md index ae3f80f910..d9cdc14f06 100644 --- a/src/engine/README.md +++ b/src/engine/README.md @@ -3,8 +3,6 @@ Engine for Hypervel [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/engine) -Ported from: https://github.com/hyperf/engine - ## Architecture Hypervel supports Swoole only. Its HTTP, WebSocket, and Reverb servers use the Swoole-specific request and response bridges from `hypervel/http-server`; Hyperf's interchangeable Swoole/Swow HTTP server and response-emitter portability layer is intentionally not part of Engine. diff --git a/src/fortify/README.md b/src/fortify/README.md index 3a7e5f9c79..2ed979f666 100644 --- a/src/fortify/README.md +++ b/src/fortify/README.md @@ -15,6 +15,6 @@ Documentation: https://hypervel.org/docs/fortify - Fortify's two-factor provider uses OTPHP instead of Google2FA and generates 32-character secrets by default. - Two-factor user models implement the `TwoFactorAuthenticationUser` contract as well as using the `TwoFactorAuthenticatable` trait. Recovery codes are consumed atomically through the user's `consumeRecoveryCode()` method; `TwoFactorLoginRequest::validRecoveryCode()` only checks a code and leaves the login challenge in the session. - Fortify omits Laravel's deprecated `Rules\Password`. -- Fortify keeps Laravel's directly writable static configuration properties private so worker-lifetime state remains typed and resettable. Use `authenticateThrough()`, `authenticateUsing()`, `confirmPasswordsUsing()`, `encryptUsing()`, and `ignoreRoutes()` instead. +- Fortify keeps Laravel's directly writable static configuration properties private so worker-lifetime state remains typed and resettable. Use `authenticateThrough()`, `authenticateUsing()`, `confirmPasswordsUsing()`, `generateRecoveryCodesUsing()`, `encryptUsing()`, and `ignoreRoutes()` instead. Ported from: https://github.com/laravel/fortify diff --git a/src/fortify/routes/routes.php b/src/fortify/routes/routes.php index 3a0d57c16b..af8704bc47 100644 --- a/src/fortify/routes/routes.php +++ b/src/fortify/routes/routes.php @@ -225,7 +225,7 @@ ->name('passkey.store'); Route::delete(RoutePath::for('passkey.destroy', '/user/passkeys/{passkey}'), [PasskeyRegistrationController::class, 'destroy']) - ->middleware($passkeyManageMiddleware) + ->middleware($passkeyMiddleware) ->name('passkey.destroy'); } }); diff --git a/src/fortify/src/Actions/RedirectIfTwoFactorAuthenticatable.php b/src/fortify/src/Actions/RedirectIfTwoFactorAuthenticatable.php index d6c7a5b707..302dd2baa5 100644 --- a/src/fortify/src/Actions/RedirectIfTwoFactorAuthenticatable.php +++ b/src/fortify/src/Actions/RedirectIfTwoFactorAuthenticatable.php @@ -18,6 +18,7 @@ use Hypervel\Fortify\LoginRateLimiter; use Hypervel\Fortify\TwoFactorAuthenticatable; use Hypervel\Http\Request; +use Hypervel\Support\Timebox; use Hypervel\Validation\ValidationException; use RuntimeException; use Symfony\Component\HttpFoundation\Response; @@ -75,21 +76,27 @@ protected function validateCredentials(Request $request): Authenticatable&Model } $provider = $this->provider(); - $user = $provider->retrieveByCredentials($request->only(Fortify::username(), 'password')); - $password = $request->input('password'); + return (new Timebox)->call(function (Timebox $timebox) use ($request, $provider): Authenticatable&Model { + $user = $provider->retrieveByCredentials($request->only(Fortify::username(), 'password')); - if (! $user instanceof Authenticatable || ! $user instanceof Model || ! $provider->validateCredentials($user, ['password' => $password])) { - $this->fireFailedEvent($request, $user); + $password = $request->input('password'); - $this->throwFailedAuthenticationException($request); - } + if (! $user instanceof Authenticatable || ! $user instanceof Model || ! $provider->validateCredentials($user, ['password' => $password])) { + $this->fireFailedEvent($request, $user); - if ($this->config->boolean('hashing.rehash_on_login')) { - $provider->rehashPasswordIfRequired($user, ['password' => $password]); - } + $this->throwFailedAuthenticationException($request); + } - return $user; + if ($this->config->boolean('hashing.rehash_on_login')) { + $provider->rehashPasswordIfRequired($user, ['password' => $password]); + } + + // Only failed attempts need a fixed duration, so successful logins skip the wait, as SessionGuard does. + $timebox->returnEarly(); + + return $user; + }, $this->config->integer('auth.timebox_duration')); } /** diff --git a/src/fortify/src/Fortify.php b/src/fortify/src/Fortify.php index 3574d33989..d479b739a3 100644 --- a/src/fortify/src/Fortify.php +++ b/src/fortify/src/Fortify.php @@ -51,6 +51,8 @@ class Fortify private static ?Closure $confirmPasswordsUsingCallback = null; + private static ?Closure $recoveryCodeGenerator = null; + private static bool $registersRoutes = self::DEFAULT_REGISTERS_ROUTES; private static ?EncrypterContract $encrypter = null; @@ -320,6 +322,24 @@ public static function resetUserPasswordsUsing(callable|string $callback): void self::container()->singleton(ResetsUserPasswords::class, self::bindingConcrete($callback)); } + /** + * Register a callback that should be used to generate recovery codes. + * + * Boot-only. The callback persists in static state for the worker lifetime and affects every subsequent recovery code generation. + */ + public static function generateRecoveryCodesUsing(callable $callback): void + { + self::$recoveryCodeGenerator = Closure::fromCallable($callback); + } + + /** + * Get the configured recovery code generator. + */ + public static function recoveryCodeGenerator(): ?Closure + { + return self::$recoveryCodeGenerator; + } + /** * Determine if Fortify is confirming two factor authentication configurations. */ @@ -426,6 +446,7 @@ public static function flushState(): void self::$authenticateThroughCallback = null; self::$authenticateUsingCallback = null; self::$confirmPasswordsUsingCallback = null; + self::$recoveryCodeGenerator = null; self::$registersRoutes = self::DEFAULT_REGISTERS_ROUTES; self::$encrypter = null; self::$redirectUsingCallbacks = []; diff --git a/src/fortify/src/Http/Controllers/AuthenticatedSessionController.php b/src/fortify/src/Http/Controllers/AuthenticatedSessionController.php index d254fa02f4..f500c1bd08 100644 --- a/src/fortify/src/Http/Controllers/AuthenticatedSessionController.php +++ b/src/fortify/src/Http/Controllers/AuthenticatedSessionController.php @@ -23,6 +23,9 @@ class AuthenticatedSessionController extends Controller { + /** + * Create a new controller instance. + */ public function __construct( private readonly Container $container, private readonly Config $config, diff --git a/src/fortify/src/Http/Controllers/ConfirmablePasswordController.php b/src/fortify/src/Http/Controllers/ConfirmablePasswordController.php index eeab15fdce..7901d40e29 100644 --- a/src/fortify/src/Http/Controllers/ConfirmablePasswordController.php +++ b/src/fortify/src/Http/Controllers/ConfirmablePasswordController.php @@ -18,6 +18,9 @@ class ConfirmablePasswordController extends Controller { + /** + * Create a new controller instance. + */ public function __construct( private readonly Container $container, ) { diff --git a/src/fortify/src/Http/Controllers/EmailVerificationNotificationController.php b/src/fortify/src/Http/Controllers/EmailVerificationNotificationController.php index caa515c99d..fc3f8e8b2f 100644 --- a/src/fortify/src/Http/Controllers/EmailVerificationNotificationController.php +++ b/src/fortify/src/Http/Controllers/EmailVerificationNotificationController.php @@ -5,6 +5,7 @@ namespace Hypervel\Fortify\Http\Controllers; use Hypervel\Contracts\Auth\MustVerifyEmail; +use Hypervel\Contracts\Support\Responsable; use Hypervel\Fortify\Contracts\EmailVerificationNotificationSentResponse; use Hypervel\Fortify\Http\Responses\RedirectAsIntended; use Hypervel\Http\JsonResponse; @@ -16,7 +17,7 @@ class EmailVerificationNotificationController extends Controller /** * Send a new email verification notification. */ - public function store(Request $request): mixed + public function store(Request $request): JsonResponse|Responsable { /** @var MustVerifyEmail $user */ $user = $request->user(); diff --git a/src/fortify/src/Http/Controllers/EmailVerificationPromptController.php b/src/fortify/src/Http/Controllers/EmailVerificationPromptController.php index 27aafc748b..5d39f84c4b 100644 --- a/src/fortify/src/Http/Controllers/EmailVerificationPromptController.php +++ b/src/fortify/src/Http/Controllers/EmailVerificationPromptController.php @@ -5,6 +5,7 @@ namespace Hypervel\Fortify\Http\Controllers; use Hypervel\Contracts\Auth\MustVerifyEmail; +use Hypervel\Contracts\Support\Responsable; use Hypervel\Fortify\Contracts\VerifyEmailViewResponse; use Hypervel\Fortify\Http\Responses\RedirectAsIntended; use Hypervel\Http\Request; @@ -15,7 +16,7 @@ class EmailVerificationPromptController extends Controller /** * Display the email verification prompt. */ - public function __invoke(Request $request): mixed + public function __invoke(Request $request): Responsable { /** @var MustVerifyEmail $user */ $user = $request->user(); diff --git a/src/fortify/src/Http/Controllers/NewPasswordController.php b/src/fortify/src/Http/Controllers/NewPasswordController.php index 365fad7218..25d0d650fd 100644 --- a/src/fortify/src/Http/Controllers/NewPasswordController.php +++ b/src/fortify/src/Http/Controllers/NewPasswordController.php @@ -25,6 +25,9 @@ class NewPasswordController extends Controller { + /** + * Create a new controller instance. + */ public function __construct( private readonly Container $container, private readonly Config $config, diff --git a/src/fortify/src/Http/Controllers/RegisteredUserController.php b/src/fortify/src/Http/Controllers/RegisteredUserController.php index f6d2e937ff..5c874aa222 100644 --- a/src/fortify/src/Http/Controllers/RegisteredUserController.php +++ b/src/fortify/src/Http/Controllers/RegisteredUserController.php @@ -20,6 +20,9 @@ class RegisteredUserController extends Controller { use DispatchesEvents; + /** + * Create a new controller instance. + */ public function __construct( private readonly Container $container, private readonly Config $config, diff --git a/src/fortify/src/Http/Controllers/TwoFactorAuthenticatedSessionController.php b/src/fortify/src/Http/Controllers/TwoFactorAuthenticatedSessionController.php index 1f4e2f2d0c..9c210692a2 100644 --- a/src/fortify/src/Http/Controllers/TwoFactorAuthenticatedSessionController.php +++ b/src/fortify/src/Http/Controllers/TwoFactorAuthenticatedSessionController.php @@ -18,11 +18,15 @@ use Hypervel\Fortify\Http\Requests\TwoFactorLoginRequest; use Hypervel\Http\Exceptions\HttpResponseException; use Hypervel\Routing\Controller; +use Symfony\Component\HttpFoundation\Response; class TwoFactorAuthenticatedSessionController extends Controller { use DispatchesEvents; + /** + * Create a new controller instance. + */ public function __construct( private readonly Container $container, ) { @@ -43,7 +47,7 @@ public function create(TwoFactorLoginRequest $request): TwoFactorChallengeViewRe /** * Attempt to authenticate a new session using the two factor authentication code. */ - public function store(TwoFactorLoginRequest $request): mixed + public function store(TwoFactorLoginRequest $request): Response|TwoFactorLoginResponse { /** @var Authenticatable&Model&TwoFactorAuthenticationUser $user */ $user = $request->challengedUser(); diff --git a/src/fortify/src/Http/Controllers/TwoFactorAuthenticationController.php b/src/fortify/src/Http/Controllers/TwoFactorAuthenticationController.php index 832b9b3315..1584290693 100644 --- a/src/fortify/src/Http/Controllers/TwoFactorAuthenticationController.php +++ b/src/fortify/src/Http/Controllers/TwoFactorAuthenticationController.php @@ -10,6 +10,7 @@ use Hypervel\Fortify\Actions\EnableTwoFactorAuthentication; use Hypervel\Fortify\Contracts\TwoFactorDisabledResponse; use Hypervel\Fortify\Contracts\TwoFactorEnabledResponse; +use Hypervel\Fortify\Fortify; use Hypervel\Http\Request; use Hypervel\Routing\Controller; @@ -25,6 +26,12 @@ public function store(Request $request, EnableTwoFactorAuthentication $enable): $enable($user, $request->boolean('force', false)); + if (Fortify::confirmsTwoFactorAuthentication() + && ! is_null($user->getAttribute('two_factor_secret')) + && is_null($user->getAttribute('two_factor_confirmed_at'))) { + $request->session()->remove('two_factor_confirming_at'); + } + return app(TwoFactorEnabledResponse::class); } diff --git a/src/fortify/src/RecoveryCode.php b/src/fortify/src/RecoveryCode.php index 75ff45da7a..32736272a6 100644 --- a/src/fortify/src/RecoveryCode.php +++ b/src/fortify/src/RecoveryCode.php @@ -13,6 +13,10 @@ class RecoveryCode */ public static function generate(): string { + if ($generator = Fortify::recoveryCodeGenerator()) { + return $generator(); + } + return Str::random(10) . '-' . Str::random(10); } } diff --git a/src/fortify/stubs/PasswordValidationRules.stub b/src/fortify/stubs/PasswordValidationRules.stub index 191212465d..a1be80fd5a 100644 --- a/src/fortify/stubs/PasswordValidationRules.stub +++ b/src/fortify/stubs/PasswordValidationRules.stub @@ -16,7 +16,6 @@ trait PasswordValidationRules */ protected function passwordRules(): array { - // Hypervel uses the framework password rule directly instead of Laravel Fortify's deprecated compatibility wrapper. return ['required', 'string', Password::default(), 'confirmed']; } } diff --git a/src/foundation/src/Console/Kernel.php b/src/foundation/src/Console/Kernel.php index a6832aebb1..fe57f774d5 100644 --- a/src/foundation/src/Console/Kernel.php +++ b/src/foundation/src/Console/Kernel.php @@ -294,7 +294,7 @@ public function commandStartedAt(): ?CarbonImmutable /** * Define the application's command schedule. */ - public function schedule(Schedule $schedule): void + protected function schedule(Schedule $schedule): void { } @@ -329,7 +329,7 @@ protected function scheduleCache(): ?string /** * Register the commands for the application. */ - public function commands(): void + protected function commands(): void { } @@ -352,7 +352,7 @@ public function command(string $signature, Closure $callback): ClosureCommand /** * Register all of the commands in the given directory. */ - public function load(array|string $paths): void + protected function load(array|string $paths): void { $paths = array_unique(Arr::wrap($paths)); diff --git a/src/horizon/src/RedisQueue.php b/src/horizon/src/RedisQueue.php index c24d12cdfb..e0d27c008c 100644 --- a/src/horizon/src/RedisQueue.php +++ b/src/horizon/src/RedisQueue.php @@ -26,6 +26,8 @@ class RedisQueue extends BaseQueue { public const string LAST_PUSHED_CONTEXT_KEY = '__horizon.queue.last_pushed'; + protected const string POPPING_QUEUE_CONTEXT_KEY = '__horizon.queue.popping'; + /** * Get the number of queue jobs that are ready to process. */ @@ -161,7 +163,17 @@ protected function handlePayloadPushedInBulk(string $payload, ?string $queue): v #[Override] public function pop(UnitEnum|string|null $queue = null, int $index = 0): ?Job { - return tap(parent::pop($queue, $index), function ($result) use ($queue) { + $name = $this->getQueue($queue); + + CoroutineContext::set(static::POPPING_QUEUE_CONTEXT_KEY, $name); + + try { + $result = parent::pop($queue, $index); + } finally { + CoroutineContext::forget(static::POPPING_QUEUE_CONTEXT_KEY); + } + + return tap($result, function ($result) use ($name) { /** @var null|RedisJob $result */ if ($result && $this->hasEventListeners(JobReserved::class)) { try { @@ -170,7 +182,7 @@ public function pop(UnitEnum|string|null $queue = null, int $index = 0): ?Job return; } - $this->event($this->getQueue($queue), $event); + $this->event($name, $event); } }); } @@ -186,7 +198,9 @@ public function migrateExpiredJobs(string $from, string $to): array { return tap(parent::migrateExpiredJobs($from, $to), function ($jobs) use ($to) { if ($this->hasEventListeners(JobsMigrated::class)) { - $this->event($to, new JobsMigrated($jobs)); + // Pop migrates through storage keys, which can carry a Cluster hash tag or a + // getQueueRedisKey() override, so its events report the resolved queue name. + $this->event(CoroutineContext::get(static::POPPING_QUEUE_CONTEXT_KEY, $to), new JobsMigrated($jobs)); } }); } diff --git a/src/http-server/README.md b/src/http-server/README.md index ac0cef712f..987d1a2626 100644 --- a/src/http-server/README.md +++ b/src/http-server/README.md @@ -2,5 +2,3 @@ HTTP Server for Hypervel === [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/http-server) - -Ported from: https://github.com/hyperf/hyperf/tree/master/src/http-server diff --git a/src/passkeys/README.md b/src/passkeys/README.md index 9b5beda169..fa9e556bc4 100644 --- a/src/passkeys/README.md +++ b/src/passkeys/README.md @@ -7,10 +7,11 @@ Documentation: https://hypervel.org/docs/fortify - Passkeys use a polymorphic `user` owner relation so multiple authenticatable model classes can share the same passkeys table. - Standalone Passkeys follows Hypervel's current default guard selected by `Auth::shouldUse()` or `auth.defaults.guard`, with optional `passkeys.guard` route-group selection for built-in standalone routes. -- Passkeys use current non-deprecated `web-auth/webauthn-lib` APIs, including `CredentialRecord`. +- The `{passkey}` route binding resolves only the authenticated owner's passkeys, so another user's passkey returns 404 instead of Laravel's 403. Routes that manage other users' passkeys should use a different parameter name. - Passkeys omit Laravel's `relyingPartyName()` because `web-auth/webauthn-lib` deprecates non-empty relying party names. - Passkeys include explicit orphan cleanup for polymorphic owners. - Passkeys support boot-time request-aware callbacks for redirects and WebAuthn relying party / origin settings, such as for custom domains, multi-guard apps, or multi-tenant apps. -- Passkey registration responses do not store request data on singleton response instances. +- Passkeys keep Laravel's directly writable `$passkeyModel` static property private so worker-lifetime state remains typed and resettable. Use `usePasskeyModel()` instead. +- Passkeys omit Laravel's protected `StorePasskey::ensureCredentialIsUnique()` pre-check because the unique `credential_id` index rejects duplicates without an extra query. Override `createPasskey()` to customize duplicate handling. Ported from: https://github.com/laravel/passkeys-server diff --git a/src/passkeys/composer.json b/src/passkeys/composer.json index 0cff23adda..4ddc9d2b8a 100644 --- a/src/passkeys/composer.json +++ b/src/passkeys/composer.json @@ -46,7 +46,6 @@ "paragonie/constant_time_encoding": "^3.1", "symfony/console": "^8.1.2", "symfony/http-foundation": "^8.1", - "symfony/http-kernel": "^8.1", "symfony/serializer": "^8.1", "web-auth/cose-lib": "^4.8.1", "web-auth/webauthn-lib": "^5.3" diff --git a/src/passkeys/routes/routes.php b/src/passkeys/routes/routes.php index 651cded03b..23e44b00fd 100644 --- a/src/passkeys/routes/routes.php +++ b/src/passkeys/routes/routes.php @@ -49,7 +49,7 @@ ->name('passkey.store'); Route::delete('/user/passkeys/{passkey}', [PasskeyRegistrationController::class, 'destroy']) - ->middleware($middleware(...$managementMiddleware)) + ->middleware($managementMiddleware) ->name('passkey.destroy'); }); }); diff --git a/src/passkeys/src/Actions/DeletePasskey.php b/src/passkeys/src/Actions/DeletePasskey.php index 3cee647619..87601074c4 100644 --- a/src/passkeys/src/Actions/DeletePasskey.php +++ b/src/passkeys/src/Actions/DeletePasskey.php @@ -6,11 +6,8 @@ use Hypervel\Contracts\Auth\Authenticatable; use Hypervel\Passkeys\Concerns\DispatchesEvents; -use Hypervel\Passkeys\Contracts\PasskeyUser; use Hypervel\Passkeys\Events\PasskeyDeleted; use Hypervel\Passkeys\Passkey; -use RuntimeException; -use Symfony\Component\HttpKernel\Exception\HttpException; class DeletePasskey { @@ -21,14 +18,6 @@ class DeletePasskey */ public function __invoke(Authenticatable $user, Passkey $passkey): void { - if (! $user instanceof PasskeyUser) { - throw new RuntimeException('User model must implement the PasskeyUser contract.'); - } - - if (! $this->passkeyBelongsToUser($passkey, $user)) { - throw new HttpException(403); - } - $passkey->delete(); $this->dispatchIfListening( @@ -36,19 +25,4 @@ public function __invoke(Authenticatable $user, Passkey $passkey): void static fn (): PasskeyDeleted => new PasskeyDeleted($user, $passkey), ); } - - /** - * Determine if the passkey belongs to the given user. - */ - private function passkeyBelongsToUser(Passkey $passkey, PasskeyUser $user): bool - { - $identifier = $user->getKey(); - - if (! is_scalar($identifier)) { - return false; - } - - return $passkey->user_type === $user->getMorphClass() - && (string) $passkey->user_id === (string) $identifier; - } } diff --git a/src/passkeys/src/Actions/StorePasskey.php b/src/passkeys/src/Actions/StorePasskey.php index 40055f837c..2952da3a34 100644 --- a/src/passkeys/src/Actions/StorePasskey.php +++ b/src/passkeys/src/Actions/StorePasskey.php @@ -99,6 +99,9 @@ protected function hostFromOptions(PublicKeyCredentialCreationOptions $options): return $options->rp->id; } + // Intentionally omitted: ensureCredentialIsUnique(). The unique credential_id + // index rejects duplicates in createPasskey() without an extra query. + /** * Create the passkey record for the user. * diff --git a/src/passkeys/src/Actions/VerifyPasskey.php b/src/passkeys/src/Actions/VerifyPasskey.php index cdbc4e2813..92a0d32405 100644 --- a/src/passkeys/src/Actions/VerifyPasskey.php +++ b/src/passkeys/src/Actions/VerifyPasskey.php @@ -6,7 +6,6 @@ use Hypervel\Auth\EloquentUserProvider; use Hypervel\Contracts\Auth\StatefulGuard; -use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Passkeys\Concerns\DispatchesEvents; use Hypervel\Passkeys\Contracts\PasskeyUser; use Hypervel\Passkeys\Events\PasskeyVerified; @@ -15,6 +14,7 @@ use Hypervel\Passkeys\Passkeys; use Hypervel\Passkeys\Support\WebAuthn; use Hypervel\Support\Facades\Date; +use Hypervel\Support\Facades\DB; use ParagonIE\ConstantTime\Base64UrlSafe; use RuntimeException; use Webauthn\AuthenticatorAssertionResponse; @@ -27,11 +27,6 @@ class VerifyPasskey { use DispatchesEvents; - public function __construct( - private readonly ConnectionResolverInterface $database, - ) { - } - /** * Validate the passkey credential and return the passkey. * @@ -48,11 +43,10 @@ public function __invoke( ? null : $this->ownerMorphClassForGuard(Passkeys::guard()); $passkeyModel = Passkeys::passkeyModel(); - /** @var Passkey $passkeyInstance */ - $passkeyInstance = new $passkeyModel; - return $this->database->connection($passkeyInstance->getConnectionName())->transaction(function () use ($credential, $options, $user, $response, $ownerType): Passkey { - $passkey = $this->getPasskey($credential, lock: true, ownerType: $ownerType); + // The row lock only holds inside a transaction on the passkey model's own connection. + return DB::connection((new $passkeyModel)->getConnectionName())->transaction(function () use ($credential, $options, $user, $response, $ownerType): Passkey { + $passkey = $this->getPasskey($credential, lock: true); $this->ensurePasskeyBelongsToUser($passkey, $user); @@ -95,7 +89,7 @@ protected function getResponse(PublicKeyCredential $credential): AuthenticatorAs * * @throws InvalidPasskeyException */ - public function getPasskey(PublicKeyCredential $credential, bool $lock = false, ?string $ownerType = null): Passkey + public function getPasskey(PublicKeyCredential $credential, bool $lock = false): Passkey { // Assertion ceremonies do not run CheckCredentialId, so enforce its CTAP2 limit before lookup. if (strlen($credential->rawId) > 1023) { @@ -107,10 +101,6 @@ public function getPasskey(PublicKeyCredential $credential, bool $lock = false, $query = $passkeyModel::query()->where('credential_id', $credentialId); - if ($ownerType !== null) { - $query->where('user_type', $ownerType); - } - if ($lock) { $query->lockForUpdate(); } @@ -151,9 +141,14 @@ public function ensurePasskeyBelongsToUser(Passkey $passkey, ?PasskeyUser $user) */ protected function resolvePasskeyOwner(Passkey $passkey, string $ownerType): PasskeyUser { + // Compare the stored type first so another provider's owner type is never resolved. + if ($passkey->user_type !== $ownerType) { + throw InvalidPasskeyException::make('Passkey not recognized. It may have been removed from your account.'); + } + $user = $passkey->user; - if (! $user instanceof PasskeyUser || $user->getMorphClass() !== $ownerType) { + if (! $user instanceof PasskeyUser) { throw InvalidPasskeyException::make('Passkey not recognized. It may have been removed from your account.'); } diff --git a/src/passkeys/src/Http/Controllers/PasskeyConfirmationController.php b/src/passkeys/src/Http/Controllers/PasskeyConfirmationController.php index 231eef1005..ca420cba2d 100644 --- a/src/passkeys/src/Http/Controllers/PasskeyConfirmationController.php +++ b/src/passkeys/src/Http/Controllers/PasskeyConfirmationController.php @@ -5,7 +5,6 @@ namespace Hypervel\Passkeys\Http\Controllers; use Hypervel\Auth\AuthenticationException; -use Hypervel\Contracts\Container\Container; use Hypervel\Http\JsonResponse; use Hypervel\Http\Request; use Hypervel\Passkeys\Actions\GenerateVerificationOptions; @@ -21,11 +20,6 @@ class PasskeyConfirmationController extends Controller { - public function __construct( - private readonly Container $container, - ) { - } - /** * Get passkey confirmation options for the authenticated user. */ @@ -42,7 +36,7 @@ public function index(Request $request, GenerateVerificationOptions $generate): $serialized = WebAuthn::toJson($options); - $request->session()->put('passkey.confirmation_options', $serialized); + $request->session()->put('passkey.verification_options_' . Passkeys::guardName(), $serialized); return response()->json([ 'options' => WebAuthn::toBrowserArray($options), @@ -67,7 +61,7 @@ public function store( $verify( $request->credential(), - $request->verificationOptions('passkey.confirmation_options'), + $request->verificationOptions(), $user ); @@ -76,6 +70,6 @@ public function store( $session->passwordConfirmed($guardName); - return $this->container->make(PasskeyConfirmationResponse::class); + return app(PasskeyConfirmationResponse::class); } } diff --git a/src/passkeys/src/Http/Controllers/PasskeyLoginController.php b/src/passkeys/src/Http/Controllers/PasskeyLoginController.php index ac24f80301..1aed696530 100644 --- a/src/passkeys/src/Http/Controllers/PasskeyLoginController.php +++ b/src/passkeys/src/Http/Controllers/PasskeyLoginController.php @@ -4,7 +4,6 @@ namespace Hypervel\Passkeys\Http\Controllers; -use Hypervel\Contracts\Container\Container; use Hypervel\Http\JsonResponse; use Hypervel\Http\Request; use Hypervel\Passkeys\Actions\GenerateVerificationOptions; @@ -19,11 +18,6 @@ class PasskeyLoginController extends Controller { - public function __construct( - private readonly Container $container, - ) { - } - /** * Get passkey login options. */ @@ -33,7 +27,7 @@ public function index(Request $request, GenerateVerificationOptions $generate): $serialized = WebAuthn::toJson($options); - $request->session()->put('passkey.login_options', $serialized); + $request->session()->put('passkey.verification_options_' . Passkeys::guardName(), $serialized); return response()->json([ 'options' => WebAuthn::toBrowserArray($options), @@ -49,7 +43,7 @@ public function store( ): PasskeyLoginResponse { $passkey = $verify( $request->credential(), - $request->verificationOptions('passkey.login_options') + $request->verificationOptions() ); $user = $passkey->user; @@ -62,6 +56,6 @@ public function store( $request->session()->regenerate(); - return $this->container->make(PasskeyLoginResponse::class); + return app(PasskeyLoginResponse::class); } } diff --git a/src/passkeys/src/Http/Controllers/PasskeyRegistrationController.php b/src/passkeys/src/Http/Controllers/PasskeyRegistrationController.php index 4bd1cb1923..86a4f87675 100644 --- a/src/passkeys/src/Http/Controllers/PasskeyRegistrationController.php +++ b/src/passkeys/src/Http/Controllers/PasskeyRegistrationController.php @@ -5,7 +5,6 @@ namespace Hypervel\Passkeys\Http\Controllers; use Hypervel\Auth\AuthenticationException; -use Hypervel\Contracts\Container\Container; use Hypervel\Http\JsonResponse; use Hypervel\Http\Request; use Hypervel\Passkeys\Actions\DeletePasskey; @@ -21,11 +20,6 @@ class PasskeyRegistrationController extends Controller { - public function __construct( - private readonly Container $container, - ) { - } - /** * Get passkey registration options for the authenticated user. */ @@ -38,7 +32,7 @@ public function index(Request $request, GenerateRegistrationOptions $generate): $serialized = WebAuthn::toJson($options); - $request->session()->put('passkey.registration_options', $serialized); + $request->session()->put('passkey.registration_options_' . Passkeys::guardName(), $serialized); return response()->json([ 'options' => WebAuthn::toBrowserArray($options), @@ -57,12 +51,12 @@ public function store( $passkey = $storePasskey( $user, - (string) $request->string('name'), + $request->string('name')->toString(), $request->credential(), $request->registrationOptions() ); - return $this->container->make(PasskeyRegistrationResponse::class)->withPasskey($passkey); + return app(PasskeyRegistrationResponse::class)->withPasskey($passkey); } /** @@ -77,6 +71,6 @@ public function destroy( $deletePasskey($user, $passkey); - return $this->container->make(PasskeyDeletedResponse::class); + return app(PasskeyDeletedResponse::class); } } diff --git a/src/passkeys/src/Http/Requests/PasskeyRegistrationRequest.php b/src/passkeys/src/Http/Requests/PasskeyRegistrationRequest.php index 6dbb1317a5..4f7b3fcdbb 100644 --- a/src/passkeys/src/Http/Requests/PasskeyRegistrationRequest.php +++ b/src/passkeys/src/Http/Requests/PasskeyRegistrationRequest.php @@ -5,6 +5,7 @@ namespace Hypervel\Passkeys\Http\Requests; use Hypervel\Foundation\Http\FormRequest; +use Hypervel\Passkeys\Passkeys; use Hypervel\Passkeys\Support\WebAuthn; use Hypervel\Validation\ValidationException; use Throwable; @@ -74,12 +75,14 @@ public function credential(): PublicKeyCredential /** * Get the registration options from the session. * + * Options are stored per guard because one session may hold pending ceremonies for several guards. + * * @throws ValidationException */ public function registrationOptions(): PublicKeyCredentialCreationOptions { /** @var null|string $serialized */ - $serialized = $this->session()->pull('passkey.registration_options'); + $serialized = $this->session()->pull('passkey.registration_options_' . Passkeys::guardName()); if (! is_string($serialized) || $serialized === '') { throw ValidationException::withMessages([ diff --git a/src/passkeys/src/Http/Requests/PasskeyVerificationRequest.php b/src/passkeys/src/Http/Requests/PasskeyVerificationRequest.php index 2956b16d40..0746f5b06b 100644 --- a/src/passkeys/src/Http/Requests/PasskeyVerificationRequest.php +++ b/src/passkeys/src/Http/Requests/PasskeyVerificationRequest.php @@ -5,6 +5,7 @@ namespace Hypervel\Passkeys\Http\Requests; use Hypervel\Foundation\Http\FormRequest; +use Hypervel\Passkeys\Passkeys; use Hypervel\Passkeys\Support\WebAuthn; use Hypervel\Validation\ValidationException; use Throwable; @@ -82,12 +83,14 @@ public function remember(): bool /** * Get the verification options from the session. * + * Options are stored per guard because one session may hold pending ceremonies for several guards. + * * @throws ValidationException */ - public function verificationOptions(string $sessionKey): PublicKeyCredentialRequestOptions + public function verificationOptions(): PublicKeyCredentialRequestOptions { /** @var null|string $serialized */ - $serialized = $this->session()->pull($sessionKey); + $serialized = $this->session()->pull('passkey.verification_options_' . Passkeys::guardName()); if (! is_string($serialized) || $serialized === '') { throw ValidationException::withMessages([ diff --git a/src/passkeys/src/Http/Responses/PasskeyRegistrationResponse.php b/src/passkeys/src/Http/Responses/PasskeyRegistrationResponse.php index 8601153e5d..331903dbbf 100644 --- a/src/passkeys/src/Http/Responses/PasskeyRegistrationResponse.php +++ b/src/passkeys/src/Http/Responses/PasskeyRegistrationResponse.php @@ -4,28 +4,28 @@ namespace Hypervel\Passkeys\Http\Responses; +use Hypervel\Contracts\Container\Transient; use Hypervel\Http\JsonResponse; use Hypervel\Http\Request; use Hypervel\Passkeys\Contracts\PasskeyRegistrationResponse as PasskeyRegistrationResponseContract; use Hypervel\Passkeys\Passkey; use Symfony\Component\HttpFoundation\Response; -class PasskeyRegistrationResponse implements PasskeyRegistrationResponseContract +class PasskeyRegistrationResponse implements PasskeyRegistrationResponseContract, Transient { - public function __construct( - private ?Passkey $passkey = null, - ) { - } + /** + * The passkey that was registered. + */ + protected ?Passkey $passkey = null; /** * Set the passkey that was registered. */ public function withPasskey(Passkey $passkey): static { - $response = clone $this; - $response->passkey = $passkey; + $this->passkey = $passkey; - return $response; + return $this; } /** diff --git a/src/passkeys/src/PasskeyAuthenticatable.php b/src/passkeys/src/PasskeyAuthenticatable.php index 5da86d1ffb..f638ea5707 100644 --- a/src/passkeys/src/PasskeyAuthenticatable.php +++ b/src/passkeys/src/PasskeyAuthenticatable.php @@ -61,13 +61,14 @@ public function hasPasskeysEnabled(): bool /** * Get the unique user handle for WebAuthn. * - * This should be a stable identifier that does not reveal PII. + * This should be a stable identifier that does not reveal PII. The morph + * class keeps polymorphic owners that share a table and key distinct. */ public function getPasskeyUserHandle(): string { return hash_hmac( 'sha256', - $this->getTable() . '|' . $this->getKey(), + $this->getMorphClass() . '|' . $this->getTable() . '|' . $this->getKey(), Passkeys::userHandleSecret(), binary: true, ); diff --git a/src/redis/README.md b/src/redis/README.md index a034f7de47..aad15ac36d 100644 --- a/src/redis/README.md +++ b/src/redis/README.md @@ -3,8 +3,6 @@ Redis for Hypervel [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/redis) -Ported from: https://github.com/hyperf/hyperf/tree/master/src/redis - ## Differences From Laravel - Hypervel uses phpredis-only pooled connections. Cluster and Sentinel settings belong to each named connection instead of Laravel's top-level cluster configuration. @@ -17,3 +15,5 @@ Ported from: https://github.com/hyperf/hyperf/tree/master/src/redis - Native `reset()` is unavailable on pooled connections because it clears authentication and database state owned by the pool. Use `discard()`, `unwatch()`, or `exec()` to finish the corresponding stateful operation. - `Redis::funnel()->acquire()` returns a caller-held concurrency lease that can be refreshed and released explicitly after work spanning multiple operations. Laravel only exposes the callback-scoped funnel API. The Redis concurrency limiter's public `acquire()` returns that lease, so Laravel's protected `acquire($id)`, `lockScript()`, `release($key, $id)`, `releaseScript()`, and `getPrefix()` hooks are not provided. Override `claimSlot()` to customize slot acquisition; the returned lease releases the slot. - Redis funnel and throttle timeout failures throw `Hypervel\Contracts\Limiters\LimiterTimeoutException`, shared with cache funnels. Laravel uses `Illuminate\Contracts\Redis\LimiterTimeoutException` for Redis limiters. + +Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Redis diff --git a/src/sentry/composer.json b/src/sentry/composer.json index 2538cd2030..fe42294c46 100644 --- a/src/sentry/composer.json +++ b/src/sentry/composer.json @@ -59,7 +59,7 @@ "nyholm/psr7": "^1.0", "psr/http-message": "^2.0", "psr/log": "^3.0", - "sentry/sentry": "dev-master", + "sentry/sentry": "^4.32", "symfony/console": "^8.1.2", "symfony/http-foundation": "^8.1", "symfony/psr-http-message-bridge": "^8.1" diff --git a/src/server-process/README.md b/src/server-process/README.md index 3f35f7c055..9269fb0340 100644 --- a/src/server-process/README.md +++ b/src/server-process/README.md @@ -4,5 +4,3 @@ Server Process for Hypervel [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/server-process) Documentation: https://hypervel.org/docs/server-processes - -Ported from: https://github.com/hyperf/hyperf/tree/master/src/process diff --git a/src/server/README.md b/src/server/README.md index 7d623b8f49..934d27cb3b 100644 --- a/src/server/README.md +++ b/src/server/README.md @@ -2,5 +2,3 @@ Server for Hypervel === [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/server) - -Ported from: https://github.com/hyperf/hyperf/tree/master/src/server diff --git a/src/signal/README.md b/src/signal/README.md index ef9e9863ca..308b8c366c 100644 --- a/src/signal/README.md +++ b/src/signal/README.md @@ -4,5 +4,3 @@ Signal for Hypervel [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/signal) Documentation: https://hypervel.org/docs/signals - -Ported from: https://github.com/hyperf/hyperf/tree/master/src/signal diff --git a/src/support/src/Facades/Artisan.php b/src/support/src/Facades/Artisan.php index 3f00ee5253..ebd374b52c 100644 --- a/src/support/src/Facades/Artisan.php +++ b/src/support/src/Facades/Artisan.php @@ -15,18 +15,15 @@ * @method static void bootstrapWithoutBootingProviders() * @method static int call(string $command, array $parameters = [], \Symfony\Component\Console\Output\OutputInterface|null $outputBuffer = null) * @method static \Hypervel\Foundation\Console\ClosureCommand command(string $signature, \Closure $callback) - * @method static void commands() * @method static \Hypervel\Support\CarbonImmutable|null commandStartedAt() * @method static \Symfony\Component\Console\Command\Command|null findCommand(string $name) * @method static \Hypervel\Contracts\Console\Application getArtisan() * @method static int handle(\Symfony\Component\Console\Input\InputInterface $input, \Symfony\Component\Console\Output\OutputInterface|null $output = null) - * @method static void load(array|string $paths) * @method static string output() * @method static \Hypervel\Foundation\Bus\PendingDispatch queue(string $command, array $parameters = []) * @method static void registerCommand(\Symfony\Component\Console\Command\Command $command) * @method static \Hypervel\Console\Scheduling\Schedule resolveConsoleSchedule() * @method static int run(\Symfony\Component\Console\Input\InputInterface|null $input = null, \Symfony\Component\Console\Output\OutputInterface|null $output = null) - * @method static void schedule(\Hypervel\Console\Scheduling\Schedule $schedule) * @method static void setArtisan(\Hypervel\Contracts\Console\Application|null $artisan) * @method static void terminate(\Symfony\Component\Console\Input\InputInterface $input, int $status) * @method static void whenCommandLifecycleIsLongerThan(\Carbon\CarbonInterval|\DateTimeInterface|int|float $threshold, callable $handler) diff --git a/src/validation/src/Concerns/ReplacesAttributes.php b/src/validation/src/Concerns/ReplacesAttributes.php index c49f2d7432..0e6e274e4d 100644 --- a/src/validation/src/Concerns/ReplacesAttributes.php +++ b/src/validation/src/Concerns/ReplacesAttributes.php @@ -116,7 +116,7 @@ protected function replaceEncoding(string $message, string $attribute, string $r */ protected function replaceExtensions(string $message, string $attribute, string $rule, array $parameters): string { - return str_replace(':values', implode(', ', $parameters), $message); + return $this->replaceMimes($message, $attribute, $rule, $parameters); } /** @@ -176,10 +176,10 @@ protected function replaceMissingIf(string $message, string $attribute, string $ */ protected function replaceMissingUnless(string $message, string $attribute, string $rule, array $parameters): string { - return str_replace([':other', ':value'], [ - $this->getDisplayableAttribute($parameters[0]), - $this->getDisplayableValue($parameters[0], $parameters[1]), - ], $message); + return $this->replaceWhileKeepingCase($message, [ + 'other' => $this->getDisplayableAttribute($parameters[0]), + 'value' => $this->getDisplayableValue($parameters[0], $parameters[1]), + ]); } /** @@ -314,7 +314,7 @@ protected function replaceRequiredArrayKeys(string $message, string $attribute, */ protected function replaceMimetypes(string $message, string $attribute, string $rule, array $parameters): string { - return str_replace(':values', implode(', ', $parameters), $message); + return $this->replaceMimes($message, $attribute, $rule, $parameters); } /** @@ -324,7 +324,16 @@ protected function replaceMimetypes(string $message, string $attribute, string $ */ protected function replaceMimes(string $message, string $attribute, string $rule, array $parameters): string { - return str_replace(':values', implode(', ', $parameters), $message); + // MIME types and extensions are shown literally, without custom display values. + return str_replace( + [':values', ':VALUES', ':Values'], + [ + implode(', ', $parameters), + Str::upper(implode(', ', $parameters)), + implode(', ', array_map(Str::ucfirst(...), $parameters)), + ], + $message, + ); } /** diff --git a/src/websocket-server/README.md b/src/websocket-server/README.md index c56c57ad6b..cbd2221d91 100644 --- a/src/websocket-server/README.md +++ b/src/websocket-server/README.md @@ -2,5 +2,3 @@ WebSocket Server for Hypervel === [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/websocket-server) - -Ported from: https://github.com/hyperf/hyperf/tree/master/src/websocket-server diff --git a/tests/Auth/RecallerTest.php b/tests/Auth/RecallerTest.php index 1f1f579218..d384ee33c4 100644 --- a/tests/Auth/RecallerTest.php +++ b/tests/Auth/RecallerTest.php @@ -86,13 +86,4 @@ public function testValidReturnsFalseWhenIdIsWhitespace(): void $this->assertFalse($recaller->valid()); } - - public function testPlainCookieStringPreservesIdentifierAndToken(): void - { - $raw = '123|token|hash'; - $recaller = new Recaller($raw); - - $this->assertSame('123', $recaller->id()); - $this->assertSame('token', $recaller->token()); - } } diff --git a/tests/Database/DatabaseQueryBuilderTest.php b/tests/Database/DatabaseQueryBuilderTest.php index 1640150d74..fed55d2945 100755 --- a/tests/Database/DatabaseQueryBuilderTest.php +++ b/tests/Database/DatabaseQueryBuilderTest.php @@ -1431,6 +1431,32 @@ public function testWhereNullSafeEqualsWithSubqueryPostgres(): void $this->assertSame([1, 'bar'], $builder->getBindings()); } + public function testWhereColumnNullSafeEqualsMySql(): void + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereColumn('first_name', '<=>', 'last_name'); + $this->assertSame('select * from `users` where `first_name` <=> `last_name`', $builder->toSql()); + } + + public function testWhereColumnNullSafeEqualsSQLite(): void + { + $builder = $this->getSQLiteBuilder(); + $builder->select('*')->from('users')->whereColumn('first_name', '<=>', 'last_name'); + $this->assertSame('select * from "users" where "first_name" is "last_name"', $builder->toSql()); + } + + public function testWhereColumnNullSafeEqualsPostgres(): void + { + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->where('id', 1)->orWhereColumn('updated_at', '<=>', 'created_at'); + $this->assertSame('select * from "users" where "id" = ? or "updated_at" is not distinct from "created_at"', $builder->toSql()); + $this->assertSame([1], $builder->getBindings()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->join('contacts', 'users.email', '<=>', 'contacts.email'); + $this->assertSame('select * from "users" inner join "contacts" on "users"."email" is not distinct from "contacts"."email"', $builder->toSql()); + } + public function testWhereBetweens(): void { $builder = $this->getBuilder(); diff --git a/tests/Fortify/AuthenticatedSessionControllerTest.php b/tests/Fortify/AuthenticatedSessionControllerTest.php index 694ee63a8e..cc996e68fa 100644 --- a/tests/Fortify/AuthenticatedSessionControllerTest.php +++ b/tests/Fortify/AuthenticatedSessionControllerTest.php @@ -5,8 +5,7 @@ namespace Hypervel\Tests\Fortify; use Hypervel\Auth\Events\Logout; -use Hypervel\Contracts\Auth\Authenticatable; -use Hypervel\Fortify\Contracts\LoginViewResponse; +use Hypervel\Fortify\Fortify; use Hypervel\Fortify\LoginRateLimiter; use Hypervel\Foundation\Http\FormRequest; use Hypervel\Foundation\Testing\RefreshDatabase; @@ -14,12 +13,13 @@ use Hypervel\RateLimiter\RateLimiter; use Hypervel\Support\Facades\Auth; use Hypervel\Support\Facades\Event; +use Hypervel\Support\Sleep; use Hypervel\Testbench\Attributes\WithMigration; -use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestWith; use ReflectionClass; use Workbench\App\Models\User; +use Workbench\Database\Factories\UserFactory; #[WithMigration] class AuthenticatedSessionControllerTest extends TestCase @@ -28,9 +28,7 @@ class AuthenticatedSessionControllerTest extends TestCase public function testTheLoginViewIsReturned(): void { - $this->mock(LoginViewResponse::class) - ->shouldReceive('toResponse') - ->andReturn(response('hello world')); + Fortify::loginView(fn (): string => 'hello world'); $response = $this->get('/login'); @@ -68,7 +66,7 @@ protected function assertUserCanBeAuthenticated(?bool $remember = null): void 'email' => 'taylor@laravel.com', 'password' => 'secret', 'remember' => $remember, - ])); + ], static fn (string|bool|null $value): bool => $value !== null)); $response->assertRedirect('/home'); } @@ -92,13 +90,18 @@ public function testValidationExceptionReturnedOnFailure(): void public function testLoginAttemptsAreThrottled(): void { - $this->mock(LoginRateLimiter::class, function ($mock) { - $mock->shouldReceive('tooManyAttempts')->andReturn(true); - $mock->shouldReceive('availableIn')->andReturn(10); - }); + // Skip the authentication timebox wait on each failed attempt. + Sleep::fake(); + + foreach (range(1, 5) as $attempt) { + $this->postJson('/login', [ + 'email' => 'taylor@hypervel.org', + 'password' => 'secret', + ])->assertStatus(422); + } $response = $this->postJson('/login', [ - 'email' => 'taylor@laravel.com', + 'email' => 'taylor@hypervel.org', 'password' => 'secret', ]); @@ -110,19 +113,13 @@ public function testLoginAttemptsAreThrottled(): void public function testCantBypassThrottleWithSpecialCharacters(string $username, string $expectedResult): void { $loginRateLimiter = new LoginRateLimiter( - $this->mock(RateLimiter::class) + $this->app->make(RateLimiter::class) ); $reflection = new ReflectionClass($loginRateLimiter); $method = $reflection->getMethod('throttleKey'); - $request = $this->mock( - Request::class, - static function ($mock) use ($username) { - $mock->shouldReceive('input')->andReturn($username); - $mock->shouldReceive('ip')->andReturn('192.168.0.1'); - } - ); + $request = Request::create('/login', 'POST', ['email' => $username], server: ['REMOTE_ADDR' => '192.168.0.1']); self::assertSame('web|' . $expectedResult . '|192.168.0.1', $method->invoke($loginRateLimiter, $request)); } @@ -163,26 +160,22 @@ public function testLockoutIsScopedToGuard(): void public function testTheUserCanLogoutOfTheApplication(): void { - Auth::guard()->setUser( - m::mock(Authenticatable::class)->shouldIgnoreMissing() - ); + $user = User::forceCreate(UserFactory::new()->raw()); - $response = $this->post('/logout'); + $response = $this->actingAs($user)->post('/logout'); $response->assertRedirect('/'); - $this->assertNull(Auth::guard()->getUser()); + $this->assertGuest(); } public function testTheUserCanLogoutOfTheApplicationUsingJsonRequest(): void { - Auth::guard()->setUser( - m::mock(Authenticatable::class)->shouldIgnoreMissing() - ); + $user = User::forceCreate(UserFactory::new()->raw()); - $response = $this->postJson('/logout'); + $response = $this->actingAs($user)->postJson('/logout'); $response->assertStatus(204); - $this->assertNull(Auth::guard()->getUser()); + $this->assertGuest(); } public function testCaseInsensitiveUsernamesCanBeUsed(): void diff --git a/tests/Fortify/AuthenticatedSessionControllerWithTwoFactorTest.php b/tests/Fortify/AuthenticatedSessionControllerWithTwoFactorTest.php index 04baefd59c..e7754d7390 100644 --- a/tests/Fortify/AuthenticatedSessionControllerWithTwoFactorTest.php +++ b/tests/Fortify/AuthenticatedSessionControllerWithTwoFactorTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Fortify; +use Carbon\CarbonInterval; use Carbon\FactoryImmutable; use Hypervel\Database\Eloquent\MissingAttributeException; use Hypervel\Database\Eloquent\Model; @@ -17,6 +18,7 @@ use Hypervel\Support\Facades\Auth; use Hypervel\Support\Facades\Event; use Hypervel\Support\Facades\Hash; +use Hypervel\Support\Sleep; use Hypervel\Testbench\Attributes\DefineEnvironment; use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Testbench\Attributes\WithMigration; @@ -177,6 +179,38 @@ public function testDoesNotRehashUserPasswordWhenRedirectingToTwoFactorChallenge $this->assertSame($user->password, $user->fresh()->password); } + #[WithConfig('auth.timebox_duration', 1000000)] + public function testFailedCredentialValidationIsTimeboxed(): void + { + Sleep::fake(); + + UserWithTwoFactor::forceCreate([ + 'name' => 'Taylor Otwell', + 'email' => 'taylor@hypervel.org', + 'password' => bcrypt('secret'), + 'two_factor_secret' => 'test-secret', + ]); + + $this->post('/login', [ + 'email' => 'taylor@hypervel.org', + 'password' => 'secret', + ])->assertRedirect('/two-factor-challenge'); + + Sleep::assertNeverSlept(); + + $this->post('/login', [ + 'email' => 'taylor@hypervel.org', + 'password' => 'wrong-password', + ])->assertSessionHasErrors('email'); + + $this->post('/login', [ + 'email' => 'missing@hypervel.org', + 'password' => 'secret', + ])->assertSessionHasErrors('email'); + + Sleep::assertSlept(static fn (CarbonInterval $duration): bool => $duration->totalMicroseconds > 500000, 2); + } + public function testTwoFactorChallengeCanBePassedViaCode(): void { Event::fake(); diff --git a/tests/Fortify/ConfirmablePasswordControllerTest.php b/tests/Fortify/ConfirmablePasswordControllerTest.php index e441931dd8..74592450d7 100644 --- a/tests/Fortify/ConfirmablePasswordControllerTest.php +++ b/tests/Fortify/ConfirmablePasswordControllerTest.php @@ -5,7 +5,6 @@ namespace Hypervel\Tests\Fortify; use Hypervel\Contracts\Foundation\Application as ApplicationContract; -use Hypervel\Fortify\Contracts\ConfirmPasswordViewResponse; use Hypervel\Fortify\Fortify; use Hypervel\Foundation\Auth\User; use Hypervel\Foundation\Testing\RefreshDatabase; @@ -35,9 +34,7 @@ protected function afterRefreshingDatabase(): void public function testTheConfirmPasswordViewIsReturned(): void { - $this->mock(ConfirmPasswordViewResponse::class) - ->shouldReceive('toResponse') - ->andReturn(response('hello world')); + Fortify::confirmPasswordView(fn (): string => 'hello world'); $response = $this->withoutExceptionHandling()->actingAs($this->user)->get( '/user/confirm-password' diff --git a/tests/Fortify/EmailVerificationNotificationControllerTest.php b/tests/Fortify/EmailVerificationNotificationControllerTest.php index b59d900f0f..4eefe0f765 100644 --- a/tests/Fortify/EmailVerificationNotificationControllerTest.php +++ b/tests/Fortify/EmailVerificationNotificationControllerTest.php @@ -4,48 +4,51 @@ namespace Hypervel\Tests\Fortify; -use Hypervel\Contracts\Auth\Authenticatable; -use Mockery as m; - +use Hypervel\Auth\Notifications\VerifyEmail; +use Hypervel\Foundation\Testing\RefreshDatabase; +use Hypervel\Support\Facades\Notification; +use Hypervel\Testbench\Attributes\WithMigration; +use Workbench\App\Models\User; +use Workbench\Database\Factories\UserFactory; + +#[WithMigration] class EmailVerificationNotificationControllerTest extends TestCase { + use RefreshDatabase; + public function testEmailVerificationNotificationCanBeSent(): void { - $user = m::mock(Authenticatable::class); + Notification::fake(); - $user->shouldReceive('hasVerifiedEmail')->andReturn(false); - $user->shouldReceive('getAuthIdentifier')->andReturn(1); - $user->shouldReceive('sendEmailVerificationNotification')->once(); + $user = User::forceCreate(UserFactory::new()->unverified()->raw()); $response = $this->from('/email/verify') ->actingAs($user) ->post('/email/verification-notification'); $response->assertRedirect('/email/verify'); + Notification::assertSentTo($user, VerifyEmail::class); } public function testUserIsRedirectIfAlreadyVerified(): void { - $user = m::mock(Authenticatable::class); + Notification::fake(); - $user->shouldReceive('hasVerifiedEmail')->andReturn(true); - $user->shouldReceive('getAuthIdentifier')->andReturn(1); - $user->shouldReceive('sendEmailVerificationNotification')->never(); + $user = User::forceCreate(UserFactory::new()->raw()); $response = $this->from('/email/verify') ->actingAs($user) ->post('/email/verification-notification'); $response->assertRedirect('/home'); + Notification::assertNothingSent(); } public function testUserIsRedirectToIntendedUrlIfAlreadyVerified(): void { - $user = m::mock(Authenticatable::class); + Notification::fake(); - $user->shouldReceive('hasVerifiedEmail')->andReturn(true); - $user->shouldReceive('getAuthIdentifier')->andReturn(1); - $user->shouldReceive('sendEmailVerificationNotification')->never(); + $user = User::forceCreate(UserFactory::new()->raw()); $response = $this->from('/email/verify') ->actingAs($user) @@ -53,5 +56,6 @@ public function testUserIsRedirectToIntendedUrlIfAlreadyVerified(): void ->post('/email/verification-notification'); $response->assertRedirect('http://foo.com/bar'); + Notification::assertNothingSent(); } } diff --git a/tests/Fortify/EmailVerificationPromptControllerTest.php b/tests/Fortify/EmailVerificationPromptControllerTest.php index 6cd29726b3..37ccae64c7 100644 --- a/tests/Fortify/EmailVerificationPromptControllerTest.php +++ b/tests/Fortify/EmailVerificationPromptControllerTest.php @@ -4,20 +4,22 @@ namespace Hypervel\Tests\Fortify; -use Hypervel\Contracts\Auth\Authenticatable; -use Hypervel\Fortify\Contracts\VerifyEmailViewResponse; -use Mockery as m; +use Hypervel\Fortify\Fortify; +use Hypervel\Foundation\Testing\RefreshDatabase; +use Hypervel\Testbench\Attributes\WithMigration; +use Workbench\App\Models\User; +use Workbench\Database\Factories\UserFactory; +#[WithMigration] class EmailVerificationPromptControllerTest extends TestCase { + use RefreshDatabase; + public function testTheEmailVerificationPromptViewIsReturned(): void { - $this->mock(VerifyEmailViewResponse::class) - ->shouldReceive('toResponse') - ->andReturn(response('hello world')); + Fortify::verifyEmailView(fn (): string => 'hello world'); - $user = m::mock(Authenticatable::class); - $user->shouldReceive('hasVerifiedEmail')->andReturn(false); + $user = User::forceCreate(UserFactory::new()->unverified()->raw()); $response = $this->actingAs($user)->get('/email/verify'); @@ -27,12 +29,7 @@ public function testTheEmailVerificationPromptViewIsReturned(): void public function testUserIsRedirectHomeIfAlreadyVerified(): void { - $this->mock(VerifyEmailViewResponse::class) - ->shouldReceive('toResponse') - ->andReturn(response('hello world')); - - $user = m::mock(Authenticatable::class); - $user->shouldReceive('hasVerifiedEmail')->andReturn(true); + $user = User::forceCreate(UserFactory::new()->raw()); $response = $this->actingAs($user)->get('/email/verify'); @@ -41,12 +38,7 @@ public function testUserIsRedirectHomeIfAlreadyVerified(): void public function testUserIsRedirectToIntendedUrlIfAlreadyVerified(): void { - $this->mock(VerifyEmailViewResponse::class) - ->shouldReceive('toResponse') - ->andReturn(response('hello world')); - - $user = m::mock(Authenticatable::class); - $user->shouldReceive('hasVerifiedEmail')->andReturn(true); + $user = User::forceCreate(UserFactory::new()->raw()); $response = $this->actingAs($user) ->withSession(['url.intended' => 'http://foo.com/bar']) diff --git a/tests/Fortify/FortifyApiTest.php b/tests/Fortify/FortifyApiTest.php index b86245c175..81d73eae79 100644 --- a/tests/Fortify/FortifyApiTest.php +++ b/tests/Fortify/FortifyApiTest.php @@ -227,6 +227,7 @@ public static function privateStaticFortifyPropertiesProvider(): array 'authentication pipeline callback' => ['authenticateThroughCallback'], 'authentication callback' => ['authenticateUsingCallback'], 'password confirmation callback' => ['confirmPasswordsUsingCallback'], + 'recovery code generator' => ['recoveryCodeGenerator'], 'route registration flag' => ['registersRoutes'], 'encrypter' => ['encrypter'], 'redirect callbacks' => ['redirectUsingCallbacks'], diff --git a/tests/Fortify/FortifyRouteTest.php b/tests/Fortify/FortifyRouteTest.php index 810ec7ec03..2398db65b9 100644 --- a/tests/Fortify/FortifyRouteTest.php +++ b/tests/Fortify/FortifyRouteTest.php @@ -36,22 +36,11 @@ public function testFortifyPasskeyRoutesUseFortifyGuardInsteadOfStandalonePasske $this->assertSame('web', config('passkeys.guard')); } - public function testPasskeyDeletionUsesPasswordConfirmationAndOmitsNullLimiter(): void - { - $route = Route::getRoutes()->getByName('passkey.destroy'); - - $this->assertNotNull($route); - - $middleware = $route->gatherMiddleware(); - - $this->assertContains('auth', $middleware); - $this->assertContains('password.confirm', $middleware); - $this->assertStringNotContainsString('throttle:', implode('|', $middleware)); - } - #[DefineEnvironment('withPasskeysLimiter')] - public function testPasskeyDeletionUsesConfiguredThrottle(): void + public function testPasskeyDeletionUsesPasswordConfirmationWithoutThePasskeyLimiter(): void { + $this->assertContains('throttle:passkeys', Route::getRoutes()->getByName('passkey.store')->gatherMiddleware()); + $route = Route::getRoutes()->getByName('passkey.destroy'); $this->assertNotNull($route); @@ -60,7 +49,7 @@ public function testPasskeyDeletionUsesConfiguredThrottle(): void $this->assertContains('auth', $middleware); $this->assertContains('password.confirm', $middleware); - $this->assertContains('throttle:passkeys', $middleware); + $this->assertStringNotContainsString('throttle:', implode('|', $middleware)); } public function testTwoFactorChallengeIsThrottledByDefault(): void diff --git a/tests/Fortify/FortifyServiceProviderTest.php b/tests/Fortify/FortifyServiceProviderTest.php index ad86ef0252..651eaaf970 100644 --- a/tests/Fortify/FortifyServiceProviderTest.php +++ b/tests/Fortify/FortifyServiceProviderTest.php @@ -9,12 +9,9 @@ use Hypervel\Fortify\Contracts\CreatesNewUsers; use Hypervel\Fortify\Contracts\RedirectsIfTwoFactorAuthenticatable; use Hypervel\Fortify\Contracts\TwoFactorAuthenticationProvider as TwoFactorAuthenticationProviderContract; -use Hypervel\Fortify\Contracts\TwoFactorDisabledResponse as TwoFactorDisabledResponseContract; -use Hypervel\Fortify\Contracts\TwoFactorEnabledResponse as TwoFactorEnabledResponseContract; use Hypervel\Fortify\Fortify; use Hypervel\Fortify\FortifyServiceProvider; -use Hypervel\Fortify\Http\Responses\TwoFactorDisabledResponse; -use Hypervel\Fortify\Http\Responses\TwoFactorEnabledResponse; +use Hypervel\Fortify\RecoveryCode; use Hypervel\Http\JsonResponse; use Hypervel\Http\Request; use Hypervel\Passkeys\Passkeys; @@ -62,19 +59,6 @@ public function toResponse(Request $request): Response $response->assertExactJson(['foo' => 'bar']); } - public function testTwoFactorResponseBindingsUseMatchingContracts(): void - { - $this->assertInstanceOf( - TwoFactorEnabledResponse::class, - $this->app->make(TwoFactorEnabledResponseContract::class) - ); - - $this->assertInstanceOf( - TwoFactorDisabledResponse::class, - $this->app->make(TwoFactorDisabledResponseContract::class) - ); - } - public function testTwoFactorAuthenticationProviderUsesFrameworkClock(): void { $timestamp = 946684800; @@ -204,6 +188,13 @@ public function testCustomRedirectIfTwoFactorAuthenticatableIsResolvedFreshAfter $this->assertNotSame($instanceA, $instanceB); } + public function testRecoveryCodeGenerationCanBeCustomized(): void + { + Fortify::generateRecoveryCodesUsing(fn (): string => 'recovery-code'); + + $this->assertSame('recovery-code', RecoveryCode::generate()); + } + public function testActionsCanBeRegisteredWithNonClosureCallables(): void { $creator = m::mock(CreatesNewUsers::class); diff --git a/tests/Fortify/InteractsWithTwoFactorStateTest.php b/tests/Fortify/InteractsWithTwoFactorStateTest.php index 53fd5d3067..e00e8a2c73 100644 --- a/tests/Fortify/InteractsWithTwoFactorStateTest.php +++ b/tests/Fortify/InteractsWithTwoFactorStateTest.php @@ -6,8 +6,11 @@ use Hypervel\Database\Eloquent\MissingAttributeException; use Hypervel\Database\Eloquent\Model; +use Hypervel\Fortify\Actions\EnableTwoFactorAuthentication; use Hypervel\Fortify\Features; +use Hypervel\Fortify\Http\Controllers\TwoFactorAuthenticationController; use Hypervel\Foundation\Testing\RefreshDatabase; +use Hypervel\Http\Request; use Hypervel\Testbench\Attributes\WithMigration; use Hypervel\Tests\Fortify\Fixtures\FormRequestInteractsWithTwoFactorState; use Hypervel\Tests\Fortify\Fixtures\UserWithTwoFactor; @@ -234,6 +237,45 @@ public function testConfirmingAtTimestampIsCurrentTime(): void $this->assertLessThanOrEqual($afterTime, $timestamp); } + public function testRepeatEnableRequestDoesNotDisablePendingConfirmation(): void + { + $user = $this->createUser([ + 'two_factor_secret' => encrypt('secret'), + 'two_factor_confirmed_at' => null, + ]); + $formRequest = $this->createFormRequestWithUser($user); + $formRequest->session()->put('two_factor_empty_at', time() - 10); + + // The settings page is loaded right after the secret was generated, so + // confirming_at gets stamped for the first time here. + $formRequest->ensureStateIsValid(); + $this->assertTrue($formRequest->session()->has('two_factor_confirming_at')); + + $secret = $user->two_factor_secret; + + // A moment later the user clicks "Enable 2FA" again without ever confirming. + // The secret already exists, so EnableTwoFactorAuthentication no-ops, but the + // controller should still clear the now-stale confirming_at value. + $enableRequest = Request::create('/user/two-factor-authentication', 'POST'); + $enableRequest->setUserResolver(fn (): UserWithTwoFactor => $user); + $enableRequest->setHypervelSession($formRequest->session()); + + app(TwoFactorAuthenticationController::class)->store( + $enableRequest, + app(EnableTwoFactorAuthentication::class) + ); + + $this->assertFalse($formRequest->session()->has('two_factor_confirming_at')); + + // The settings page loads again, sees a fresh confirmation attempt starting, + // and should not treat it as abandoned. + $formRequest = $this->createFormRequestWithUser($user); + $formRequest->ensureStateIsValid(); + + $this->assertEquals($secret, $user->fresh()->two_factor_secret); + $this->assertTrue($formRequest->session()->has('two_factor_confirming_at')); + } + private function createUser(array $attributes = []): UserWithTwoFactor { $defaults = [ diff --git a/tests/Fortify/NewPasswordControllerTest.php b/tests/Fortify/NewPasswordControllerTest.php index 31f97a58d8..aa9673453e 100644 --- a/tests/Fortify/NewPasswordControllerTest.php +++ b/tests/Fortify/NewPasswordControllerTest.php @@ -4,22 +4,27 @@ namespace Hypervel\Tests\Fortify; +use Hypervel\Auth\Events\PasswordReset; use Hypervel\Contracts\Auth\PasswordBroker; -use Hypervel\Fortify\Contracts\ResetPasswordViewResponse; +use Hypervel\Database\Eloquent\Model; use Hypervel\Fortify\Contracts\ResetsUserPasswords; use Hypervel\Fortify\Fortify; -use Hypervel\Support\Facades\Auth; +use Hypervel\Foundation\Testing\RefreshDatabase; +use Hypervel\Support\Facades\Event; use Hypervel\Support\Facades\Password; +use Hypervel\Testbench\Attributes\WithMigration; use Mockery as m; use Workbench\App\Models\User; +use Workbench\Database\Factories\UserFactory; +#[WithMigration] class NewPasswordControllerTest extends TestCase { + use RefreshDatabase; + public function testTheNewPasswordViewIsReturned(): void { - $this->mock(ResetPasswordViewResponse::class) - ->shouldReceive('toResponse') - ->andReturn(response('hello world')); + Fortify::resetPasswordView(fn (): string => 'hello world'); $response = $this->get('/reset-password/token'); @@ -29,45 +34,37 @@ public function testTheNewPasswordViewIsReturned(): void public function testPasswordCanBeReset(): void { - Password::shouldReceive('broker')->andReturn($broker = m::mock(PasswordBroker::class)); - - $user = m::mock(TestNewPasswordUser::class)->makePartial(); + Event::fake([PasswordReset::class]); - $user->shouldReceive('setRememberToken')->once(); - $user->shouldReceive('save')->once(); + $user = User::forceCreate(UserFactory::new()->raw(['email' => 'taylor@hypervel.org'])); + $token = Password::broker()->createToken($user); - $updater = $this->mock(ResetsUserPasswords::class); - $updater->shouldReceive('reset')->once()->with($user, m::type('array')); - - $broker->shouldReceive('reset')->andReturnUsing(function ($input, $callback) use ($user) { - $callback($user, 'password'); - - return Password::PASSWORD_RESET; - }); + $this->mock(ResetsUserPasswords::class) + ->shouldReceive('reset') + ->once() + ->with(m::on(fn (Model $resetUser): bool => $resetUser->is($user)), m::type('array')); $response = $this->withoutExceptionHandling()->post('/reset-password', [ - 'token' => 'token', - 'email' => 'taylor@laravel.com', - 'password' => 'password', - 'password_confirmation' => 'password', + 'token' => $token, + 'email' => 'taylor@hypervel.org', + 'password' => 'new-password', + 'password_confirmation' => 'new-password', ]); $response->assertStatus(302); $response->assertRedirect(Fortify::redirects('password-reset', route('login'))); - $this->assertNull(Auth::getUser()); + $this->assertGuest(); + $this->assertNotSame($user->remember_token, $user->fresh()->remember_token); + Event::assertDispatched(PasswordReset::class); } public function testPasswordResetCanFail(): void { - Password::shouldReceive('broker')->andReturn($broker = m::mock(PasswordBroker::class)); - - $broker->shouldReceive('reset')->andReturnUsing(function ($input, $callback) { - return Password::INVALID_TOKEN; - }); + User::forceCreate(UserFactory::new()->raw(['email' => 'taylor@hypervel.org'])); $response = $this->withoutExceptionHandling()->post('/reset-password', [ 'token' => 'token', - 'email' => 'taylor@laravel.com', + 'email' => 'taylor@hypervel.org', 'password' => 'password', 'password_confirmation' => 'password', ]); @@ -78,15 +75,11 @@ public function testPasswordResetCanFail(): void public function testPasswordResetCanFailWithJson(): void { - Password::shouldReceive('broker')->andReturn($broker = m::mock(PasswordBroker::class)); - - $broker->shouldReceive('reset')->andReturnUsing(function ($input, $callback) { - return Password::INVALID_TOKEN; - }); + User::forceCreate(UserFactory::new()->raw(['email' => 'taylor@hypervel.org'])); $response = $this->postJson('/reset-password', [ 'token' => 'token', - 'email' => 'taylor@laravel.com', + 'email' => 'taylor@hypervel.org', 'password' => 'password', 'password_confirmation' => 'password', ]); @@ -100,15 +93,15 @@ public function testPasswordCanBeResetWithCustomizedEmailAddressField(): void $this->app->make('config')->set('fortify.email', 'emailAddress'); Password::shouldReceive('broker')->andReturn($broker = m::mock(PasswordBroker::class)); - $user = m::mock(TestNewPasswordUser::class)->makePartial(); + $user = User::forceCreate(UserFactory::new()->raw()); + $rememberToken = $user->remember_token; - $user->shouldReceive('setRememberToken')->once(); - $user->shouldReceive('save')->once(); - - $updater = $this->mock(ResetsUserPasswords::class); - $updater->shouldReceive('reset')->once()->with($user, m::type('array')); + $this->mock(ResetsUserPasswords::class) + ->shouldReceive('reset') + ->once() + ->with($user, m::type('array')); - $broker->shouldReceive('reset')->andReturnUsing(function ($input, $callback) use ($user) { + $broker->shouldReceive('reset')->once()->andReturnUsing(function (array $input, callable $callback) use ($user): string { $callback($user, 'password'); return Password::PASSWORD_RESET; @@ -123,7 +116,8 @@ public function testPasswordCanBeResetWithCustomizedEmailAddressField(): void $response->assertStatus(302); $response->assertRedirect(Fortify::redirects('password-reset', route('login'))); - $this->assertNull(Auth::getUser()); + $this->assertGuest(); + $this->assertNotSame($rememberToken, $user->remember_token); } public function testPasswordIsRequired(): void @@ -140,41 +134,23 @@ public function testPasswordIsRequired(): void public function testCaseInsensitiveUsernamesCanBeUsed(): void { $this->app->make('config')->set('fortify.lowercase_usernames', true); - Password::shouldReceive('broker')->andReturn($broker = m::mock(PasswordBroker::class)); - - $user = m::mock(TestNewPasswordUser::class)->makePartial(); - $user->shouldReceive('setRememberToken')->once(); - $user->shouldReceive('save')->once(); + $user = User::forceCreate(UserFactory::new()->raw(['email' => 'john.doe@example.com'])); + $token = Password::broker()->createToken($user); - $updater = $this->mock(ResetsUserPasswords::class); - $updater->shouldReceive('reset')->once()->with($user, m::type('array')); - - $broker->shouldReceive('reset') + $this->mock(ResetsUserPasswords::class) + ->shouldReceive('reset') ->once() - ->with( - m::on(fn ($credentials) => $credentials['email'] === 'john.doe@example.com'), - m::type('callable') - ) - ->andReturnUsing(function ($input, $callback) use ($user) { - $callback($user, 'password'); - - return Password::PASSWORD_RESET; - }); + ->with(m::on(fn (Model $resetUser): bool => $resetUser->is($user)), m::type('array')); $response = $this->withoutExceptionHandling()->post('/reset-password', [ - 'token' => 'token', + 'token' => $token, 'email' => 'John.Doe@example.com', - 'password' => 'password', - 'password_confirmation' => 'password', + 'password' => 'new-password', + 'password_confirmation' => 'new-password', ]); $response->assertStatus(302); $response->assertRedirect(Fortify::redirects('password-reset', route('login'))); - $this->assertNull(Auth::getUser()); } } - -class TestNewPasswordUser extends User -{ -} diff --git a/tests/Fortify/PasswordControllerTest.php b/tests/Fortify/PasswordControllerTest.php index 2f4e61e8a9..048a8986fa 100644 --- a/tests/Fortify/PasswordControllerTest.php +++ b/tests/Fortify/PasswordControllerTest.php @@ -4,14 +4,12 @@ namespace Hypervel\Tests\Fortify; -use Hypervel\Contracts\Auth\PasswordBroker; use Hypervel\Fortify\Contracts\UpdatesUserPasswords; use Hypervel\Foundation\Testing\RefreshDatabase; use Hypervel\Support\Facades\Password; use Hypervel\Testbench\Attributes\WithMigration; use Hypervel\Tests\Fortify\Fixtures\UpdateUserPassword; use Hypervel\Validation\ValidationException; -use Mockery as m; use Workbench\App\Models\User; use Workbench\Database\Factories\UserFactory; @@ -24,11 +22,7 @@ public function testPasswordsCanBeUpdated(): void { $user = $this->createUser(); - Password::shouldReceive('broker')->andReturn($broker = m::mock(PasswordBroker::class)); - - $broker->shouldReceive('deleteToken') - ->once() - ->with($user); + Password::broker()->createToken($user); $this->mock(UpdatesUserPasswords::class) ->shouldReceive('update') @@ -46,6 +40,7 @@ public function testPasswordsCanBeUpdated(): void ]); $response->assertStatus(200); + $this->assertDatabaseMissing('password_reset_tokens', ['email' => $user->email]); } public function testPasswordsCannotBeUpdatedWithoutCurrentPassword(): void diff --git a/tests/Fortify/PasswordResetLinkRequestControllerTest.php b/tests/Fortify/PasswordResetLinkRequestControllerTest.php index 6ceadbd3b4..167173906f 100644 --- a/tests/Fortify/PasswordResetLinkRequestControllerTest.php +++ b/tests/Fortify/PasswordResetLinkRequestControllerTest.php @@ -4,18 +4,27 @@ namespace Hypervel\Tests\Fortify; +use Hypervel\Auth\Notifications\ResetPassword; use Hypervel\Contracts\Auth\PasswordBroker; -use Hypervel\Fortify\Contracts\RequestPasswordResetLinkViewResponse; +use Hypervel\Fortify\Fortify; +use Hypervel\Foundation\Testing\RefreshDatabase; +use Hypervel\Support\Facades\Notification; use Hypervel\Support\Facades\Password; +use Hypervel\Testbench\Attributes\WithConfig; +use Hypervel\Testbench\Attributes\WithMigration; use Mockery as m; +use Workbench\App\Models\User; +use Workbench\Database\Factories\UserFactory; +#[WithMigration] +#[WithConfig('auth.providers.users.model', User::class)] class PasswordResetLinkRequestControllerTest extends TestCase { + use RefreshDatabase; + public function testTheResetLinkRequestViewIsReturned(): void { - $this->mock(RequestPasswordResetLinkViewResponse::class) - ->shouldReceive('toResponse') - ->andReturn(response('hello world')); + Fortify::requestPasswordResetLinkView(fn (): string => 'hello world'); $response = $this->get('/forgot-password'); @@ -25,24 +34,23 @@ public function testTheResetLinkRequestViewIsReturned(): void public function testResetLinkCanBeSuccessfullyRequested(): void { - Password::shouldReceive('broker')->andReturn($broker = m::mock(PasswordBroker::class)); + Notification::fake(); - $broker->shouldReceive('sendResetLink')->andReturn(Password::RESET_LINK_SENT); + $user = User::forceCreate(UserFactory::new()->raw(['email' => 'taylor@hypervel.org'])); $response = $this->from(url('/forgot-password')) - ->post('/forgot-password', ['email' => 'taylor@laravel.com']); + ->post('/forgot-password', ['email' => 'taylor@hypervel.org']); $response->assertStatus(302); $response->assertRedirect('/forgot-password'); $response->assertSessionHasNoErrors(); $response->assertSessionHas('status', trans(Password::RESET_LINK_SENT)); + Notification::assertSentTo($user, ResetPassword::class); } public function testResetLinkRequestCanFail(): void { - Password::shouldReceive('broker')->andReturn($broker = m::mock(PasswordBroker::class)); - - $broker->shouldReceive('sendResetLink')->andReturn(Password::INVALID_USER); + Notification::fake(); $response = $this->from(url('/forgot-password')) ->post('/forgot-password', ['email' => 'taylor@laravel.com']); @@ -50,19 +58,19 @@ public function testResetLinkRequestCanFail(): void $response->assertStatus(302); $response->assertRedirect('/forgot-password'); $response->assertSessionHasErrors('email'); + Notification::assertNothingSent(); } public function testResetLinkRequestCanFailWithJson(): void { - Password::shouldReceive('broker')->andReturn($broker = m::mock(PasswordBroker::class)); - - $broker->shouldReceive('sendResetLink')->andReturn(Password::INVALID_USER); + Notification::fake(); $response = $this->from(url('/forgot-password')) ->postJson('/forgot-password', ['email' => 'taylor@laravel.com']); $response->assertStatus(422); $response->assertJsonValidationErrors('email'); + Notification::assertNothingSent(); } public function testResetLinkCanBeSuccessfullyRequestedWithCustomizedEmailField(): void @@ -70,7 +78,7 @@ public function testResetLinkCanBeSuccessfullyRequestedWithCustomizedEmailField( $this->app->make('config')->set('fortify.email', 'emailAddress'); Password::shouldReceive('broker')->andReturn($broker = m::mock(PasswordBroker::class)); - $broker->shouldReceive('sendResetLink')->andReturn(Password::RESET_LINK_SENT); + $broker->shouldReceive('sendResetLink')->once()->andReturn(Password::RESET_LINK_SENT); $response = $this->from(url('/forgot-password')) ->post('/forgot-password', ['emailAddress' => 'taylor@laravel.com']); @@ -84,16 +92,17 @@ public function testResetLinkCanBeSuccessfullyRequestedWithCustomizedEmailField( public function testCaseInsensitiveUsernamesCanBeUsed(): void { $this->app->make('config')->set('fortify.lowercase_usernames', true); - Password::shouldReceive('broker')->andReturn($broker = m::mock(PasswordBroker::class)); + Notification::fake(); - $broker->shouldReceive('sendResetLink')->andReturn(Password::RESET_LINK_SENT); + $user = User::forceCreate(UserFactory::new()->raw(['email' => 'taylor@hypervel.org'])); $response = $this->from(url('/forgot-password')) - ->post('/forgot-password', ['email' => 'TAYLOR@laravel.com']); + ->post('/forgot-password', ['email' => 'TAYLOR@hypervel.org']); $response->assertStatus(302); $response->assertRedirect('/forgot-password'); $response->assertSessionHasNoErrors(); $response->assertSessionHas('status', trans(Password::RESET_LINK_SENT)); + Notification::assertSentTo($user, ResetPassword::class); } } diff --git a/tests/Fortify/ProfileInformationControllerTest.php b/tests/Fortify/ProfileInformationControllerTest.php index 685e174cd2..3d5e8e0877 100644 --- a/tests/Fortify/ProfileInformationControllerTest.php +++ b/tests/Fortify/ProfileInformationControllerTest.php @@ -4,15 +4,20 @@ namespace Hypervel\Tests\Fortify; -use Hypervel\Contracts\Auth\Authenticatable; use Hypervel\Fortify\Contracts\UpdatesUserProfileInformation; -use Mockery as m; +use Hypervel\Foundation\Testing\RefreshDatabase; +use Hypervel\Testbench\Attributes\WithMigration; +use Workbench\App\Models\User; +use Workbench\Database\Factories\UserFactory; +#[WithMigration] class ProfileInformationControllerTest extends TestCase { + use RefreshDatabase; + public function testContactInformationCanBeUpdated(): void { - $user = m::mock(Authenticatable::class); + $user = User::forceCreate(UserFactory::new()->raw()); $this->mock(UpdatesUserProfileInformation::class) ->shouldReceive('update') @@ -30,7 +35,7 @@ public function testEmailAddressWillBeUpdatedCaseInsensitive(): void { $this->app->make('config')->set('fortify.lowercase_usernames', true); - $user = m::mock(Authenticatable::class); + $user = User::forceCreate(UserFactory::new()->raw()); $this->mock(UpdatesUserProfileInformation::class) ->shouldReceive('update') diff --git a/tests/Fortify/RegisteredUserControllerTest.php b/tests/Fortify/RegisteredUserControllerTest.php index 8998daa42c..bf0ece28a4 100644 --- a/tests/Fortify/RegisteredUserControllerTest.php +++ b/tests/Fortify/RegisteredUserControllerTest.php @@ -4,20 +4,22 @@ namespace Hypervel\Tests\Fortify; -use Hypervel\Contracts\Auth\Authenticatable; -use Hypervel\Contracts\Auth\Factory as AuthFactory; -use Hypervel\Contracts\Auth\StatefulGuard; use Hypervel\Fortify\Contracts\CreatesNewUsers; -use Hypervel\Fortify\Contracts\RegisterViewResponse; -use Mockery as m; - +use Hypervel\Fortify\Fortify; +use Hypervel\Foundation\Testing\RefreshDatabase; +use Hypervel\Support\Facades\Auth; +use Hypervel\Testbench\Attributes\WithMigration; +use Workbench\App\Models\User; +use Workbench\Database\Factories\UserFactory; + +#[WithMigration] class RegisteredUserControllerTest extends TestCase { + use RefreshDatabase; + public function testTheRegisterViewIsReturned(): void { - $this->mock(RegisterViewResponse::class) - ->shouldReceive('toResponse') - ->andReturn(response('hello world')); + Fortify::registerView(fn (): string => 'hello world'); $response = $this->get('/register'); @@ -29,27 +31,25 @@ public function testUsersCanBeCreated(): void { $this->mock(CreatesNewUsers::class) ->shouldReceive('create') - ->andReturn(m::mock(Authenticatable::class)); - - $this->expectsGuardLogin(); + ->andReturn($user = User::forceCreate(UserFactory::new()->raw())); $response = $this->post('/register', []); $response->assertRedirect('/home'); + $this->assertAuthenticatedAs($user); } public function testUsersCanBeCreatedAndRedirectedToIntendedUrl(): void { $this->mock(CreatesNewUsers::class) ->shouldReceive('create') - ->andReturn(m::mock(Authenticatable::class)); - - $this->expectsGuardLogin(); + ->andReturn($user = User::forceCreate(UserFactory::new()->raw())); $response = $this->withSession(['url.intended' => 'http://foo.com/bar']) ->post('/register', []); $response->assertRedirect('http://foo.com/bar'); + $this->assertAuthenticatedAs($user); } public function testUsernamesWillBeStoredCaseInsensitive(): void @@ -63,9 +63,7 @@ public function testUsernamesWillBeStoredCaseInsensitive(): void 'password' => 'password', ]) ->once() - ->andReturn(m::mock(Authenticatable::class)); - - $this->expectsGuardLogin(); + ->andReturn($user = User::forceCreate(UserFactory::new()->raw())); $response = $this->post('/register', [ 'email' => 'TAYLOR@LARAVEL.COM', @@ -73,6 +71,7 @@ public function testUsernamesWillBeStoredCaseInsensitive(): void ]); $response->assertRedirect('/home'); + $this->assertAuthenticatedAs($user); } public function testUsersCanBeCreatedWithRememberOption(): void @@ -80,9 +79,7 @@ public function testUsersCanBeCreatedWithRememberOption(): void $this->mock(CreatesNewUsers::class) ->shouldReceive('create') ->once() - ->andReturn(m::mock(Authenticatable::class)); - - $this->expectsGuardLogin(remember: true); + ->andReturn($user = User::forceCreate(UserFactory::new()->raw())); $response = $this->post('/register', [ 'email' => 'taylor@laravel.com', @@ -91,20 +88,7 @@ public function testUsersCanBeCreatedWithRememberOption(): void ]); $response->assertRedirect('/home'); - } - - private function expectsGuardLogin(bool $remember = false): void - { - $guard = m::mock(StatefulGuard::class); - $guard->shouldReceive('login') - ->with(m::type(Authenticatable::class), $remember) - ->once(); - - $auth = m::mock(AuthFactory::class); - $auth->shouldReceive('guard') - ->with(null) - ->andReturn($guard); - - $this->app->instance(AuthFactory::class, $auth); + $this->assertAuthenticatedAs($user); + $response->assertCookie(Auth::guard()->getRecallerName()); } } diff --git a/tests/Fortify/ResponseBindingTest.php b/tests/Fortify/ResponseBindingTest.php new file mode 100644 index 0000000000..4c021894b3 --- /dev/null +++ b/tests/Fortify/ResponseBindingTest.php @@ -0,0 +1,62 @@ +assertTrue( + is_a($response, $contract, true), + "The [{$response}] class should implement the [{$contract}] contract." + ); + + // Password reset responses receive the broker status from their controller. + $this->assertInstanceOf( + $response, + $this->app->make($contract, ['status' => 'passwords.sent']), + "The [{$contract}] contract should resolve to [{$response}]." + ); + } + + /** + * The response bindings registered by the Fortify service provider. + * + * Mirrors {@see FortifyServiceProvider::registerResponseBindings()}. + * + * @return array + */ + public static function responseBindingsProvider(): array + { + return [ + 'EmailVerificationNotificationSentResponse' => [Contracts\EmailVerificationNotificationSentResponse::class, Responses\EmailVerificationNotificationSentResponse::class], + 'FailedPasswordConfirmationResponse' => [Contracts\FailedPasswordConfirmationResponse::class, Responses\FailedPasswordConfirmationResponse::class], + 'FailedPasswordResetLinkRequestResponse' => [Contracts\FailedPasswordResetLinkRequestResponse::class, Responses\FailedPasswordResetLinkRequestResponse::class], + 'FailedPasswordResetResponse' => [Contracts\FailedPasswordResetResponse::class, Responses\FailedPasswordResetResponse::class], + 'FailedTwoFactorLoginResponse' => [Contracts\FailedTwoFactorLoginResponse::class, Responses\FailedTwoFactorLoginResponse::class], + 'LockoutResponse' => [Contracts\LockoutResponse::class, Responses\LockoutResponse::class], + 'LoginResponse' => [Contracts\LoginResponse::class, Responses\LoginResponse::class], + 'LogoutResponse' => [Contracts\LogoutResponse::class, Responses\LogoutResponse::class], + 'PasswordConfirmedResponse' => [Contracts\PasswordConfirmedResponse::class, Responses\PasswordConfirmedResponse::class], + 'PasswordResetResponse' => [Contracts\PasswordResetResponse::class, Responses\PasswordResetResponse::class], + 'PasswordUpdateResponse' => [Contracts\PasswordUpdateResponse::class, Responses\PasswordUpdateResponse::class], + 'ProfileInformationUpdatedResponse' => [Contracts\ProfileInformationUpdatedResponse::class, Responses\ProfileInformationUpdatedResponse::class], + 'RecoveryCodesGeneratedResponse' => [Contracts\RecoveryCodesGeneratedResponse::class, Responses\RecoveryCodesGeneratedResponse::class], + 'RegisterResponse' => [Contracts\RegisterResponse::class, Responses\RegisterResponse::class], + 'SuccessfulPasswordResetLinkRequestResponse' => [Contracts\SuccessfulPasswordResetLinkRequestResponse::class, Responses\SuccessfulPasswordResetLinkRequestResponse::class], + 'TwoFactorConfirmedResponse' => [Contracts\TwoFactorConfirmedResponse::class, Responses\TwoFactorConfirmedResponse::class], + 'TwoFactorDisabledResponse' => [Contracts\TwoFactorDisabledResponse::class, Responses\TwoFactorDisabledResponse::class], + 'TwoFactorEnabledResponse' => [Contracts\TwoFactorEnabledResponse::class, Responses\TwoFactorEnabledResponse::class], + 'TwoFactorLoginResponse' => [Contracts\TwoFactorLoginResponse::class, Responses\TwoFactorLoginResponse::class], + 'VerifyEmailResponse' => [Contracts\VerifyEmailResponse::class, Responses\VerifyEmailResponse::class], + ]; + } +} diff --git a/tests/Fortify/TestCase.php b/tests/Fortify/TestCase.php index 71e2f2c2aa..d5108c554f 100644 --- a/tests/Fortify/TestCase.php +++ b/tests/Fortify/TestCase.php @@ -13,7 +13,6 @@ use Hypervel\Support\Facades\Schema; use Hypervel\Testbench\TestCase as TestbenchTestCase; use Hypervel\Tests\Fortify\Fixtures\Admin; -use Workbench\App\Models\User; abstract class TestCase extends TestbenchTestCase { @@ -34,7 +33,7 @@ protected function getPackageProviders(ApplicationContract $app): array protected function defineEnvironment(ApplicationContract $app): void { $config = $app->make(Config::class); - $userModel = $config->get('auth.providers.users.model', User::class); + $userModel = $config->string('auth.providers.users.model'); $config->set([ 'app.key' => 'base64:' . base64_encode(str_repeat('a', 32)), @@ -61,32 +60,8 @@ protected function defineEnvironment(ApplicationContract $app): void } /** - * Create fixture tables after refreshing the database. + * Create the admins table. */ - protected function afterRefreshingDatabase(): void - { - Schema::create('users', function (Blueprint $table): void { - $table->id(); - $table->string('name')->nullable(); - $table->string('email')->unique(); - $table->timestamp('email_verified_at')->nullable(); - $table->string('password')->nullable(); - $table->text('two_factor_secret')->nullable(); - $table->text('two_factor_recovery_codes')->nullable(); - $table->timestamp('two_factor_confirmed_at')->nullable(); - $table->rememberToken(); - $table->timestamps(); - }); - - $this->createAdminsTable(); - - Schema::create('password_reset_tokens', function (Blueprint $table): void { - $table->string('email')->primary(); - $table->string('token'); - $table->timestamp('created_at')->nullable(); - }); - } - protected function createAdminsTable(): void { Schema::create('admins', function (Blueprint $table): void { diff --git a/tests/Fortify/VerifyEmailControllerTest.php b/tests/Fortify/VerifyEmailControllerTest.php index f777b65940..bb47ed7804 100644 --- a/tests/Fortify/VerifyEmailControllerTest.php +++ b/tests/Fortify/VerifyEmailControllerTest.php @@ -4,12 +4,12 @@ namespace Hypervel\Tests\Fortify; -use Hypervel\Contracts\Auth\Authenticatable; +use Hypervel\Auth\Events\Verified; use Hypervel\Foundation\Http\FormRequest; use Hypervel\Foundation\Testing\RefreshDatabase; +use Hypervel\Support\Facades\Event; use Hypervel\Support\Facades\URL; use Hypervel\Testbench\Attributes\WithMigration; -use Mockery as m; use Workbench\App\Models\User; use Workbench\Database\Factories\UserFactory; @@ -50,71 +50,72 @@ protected function assertEmailCanBeVerified(): void ->get($url); $response->assertRedirect('http://foo.com/bar'); + $this->assertTrue($user->fresh()->hasVerifiedEmail()); } public function testRedirectedIfEmailIsAlreadyVerified(): void { + Event::fake([Verified::class]); + + $user = User::forceCreate(UserFactory::new()->raw([ + 'email' => 'taylor@hypervel.org', + ])); + $url = URL::temporarySignedRoute( 'verification.verify', now()->addMinutes(60), [ - 'id' => 1, - 'hash' => sha1('taylor@laravel.com'), + 'id' => $user->getKey(), + 'hash' => sha1('taylor@hypervel.org'), ] ); - $user = m::mock(Authenticatable::class); - $user->shouldReceive('getKey')->andReturn(1); - $user->shouldReceive('getAuthIdentifier')->andReturn(1); - $user->shouldReceive('getEmailForVerification')->andReturn('taylor@laravel.com'); - $user->shouldReceive('hasVerifiedEmail')->andReturn(true); - $user->shouldReceive('markEmailAsVerified')->never(); - $response = $this->actingAs($user)->get($url); $response->assertStatus(302); + Event::assertNotDispatched(Verified::class); } public function testEmailIsNotVerifiedIfIdDoesNotMatch(): void { + $user = $this->createUnverifiedUser([ + 'email' => 'taylor@hypervel.org', + ]); + $url = URL::temporarySignedRoute( 'verification.verify', now()->addMinutes(60), [ - 'id' => 2, - 'hash' => sha1('taylor@laravel.com'), + 'id' => $user->getKey() + 1, + 'hash' => sha1('taylor@hypervel.org'), ] ); - $user = m::mock(Authenticatable::class); - $user->shouldReceive('getKey')->andReturn(1); - $user->shouldReceive('getAuthIdentifier')->andReturn(1); - $user->shouldReceive('getEmailForVerification')->andReturn('taylor@laravel.com'); - $response = $this->actingAs($user)->get($url); $response->assertStatus(403); + $this->assertFalse($user->fresh()->hasVerifiedEmail()); } public function testEmailIsNotVerifiedIfEmailDoesNotMatch(): void { + $user = $this->createUnverifiedUser([ + 'email' => 'taylor@hypervel.org', + ]); + $url = URL::temporarySignedRoute( 'verification.verify', now()->addMinutes(60), [ - 'id' => 1, - 'hash' => sha1('abigail@laravel.com'), + 'id' => $user->getKey(), + 'hash' => sha1('abigail@hypervel.org'), ] ); - $user = m::mock(Authenticatable::class); - $user->shouldReceive('getKey')->andReturn(1); - $user->shouldReceive('getAuthIdentifier')->andReturn(1); - $user->shouldReceive('getEmailForVerification')->andReturn('taylor@laravel.com'); - $response = $this->actingAs($user)->get($url); $response->assertStatus(403); + $this->assertFalse($user->fresh()->hasVerifiedEmail()); } /** diff --git a/tests/Foundation/Console/KernelTest.php b/tests/Foundation/Console/KernelTest.php index 02bbcde614..ec7b6a8888 100644 --- a/tests/Foundation/Console/KernelTest.php +++ b/tests/Foundation/Console/KernelTest.php @@ -9,6 +9,7 @@ use Hypervel\Console\Command; use Hypervel\Console\Scheduling\CacheEventMutex; use Hypervel\Console\Scheduling\CacheSchedulingMutex; +use Hypervel\Console\Scheduling\Schedule; use Hypervel\Contracts\Console\Application as ConsoleApplicationContract; use Hypervel\Contracts\Console\Kernel as KernelContract; use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; @@ -263,6 +264,44 @@ public function testCommand(): void } } + public function testApplicationKernelsCanOverrideTheProtectedConsoleHooks(): void + { + $kernel = new class($this->app, $this->app->make('events')) extends Kernel { + public array $calls = []; + + /** + * Record that the schedule was defined. + */ + protected function schedule(Schedule $schedule): void + { + $this->calls[] = 'schedule'; + } + + /** + * Load commands the way a Laravel application kernel does. + */ + protected function commands(): void + { + $this->load(__DIR__ . '/Commands'); + } + + /** + * Record the loaded paths before registering their commands. + */ + protected function load(array|string $paths): void + { + $this->calls[] = $paths; + + parent::load($paths); + } + }; + + $kernel->bootstrap(); + $kernel->resolveConsoleSchedule(); + + $this->assertSame([__DIR__ . '/Commands', 'schedule'], $kernel->calls); + } + public function testSetArtisanSynchronizesTheKernelAndContainerBeforeReboundCallbacks(): void { $kernel = $this->app->make(KernelContract::class); diff --git a/tests/Horizon/Unit/RedisQueueTest.php b/tests/Horizon/Unit/RedisQueueTest.php index 84f1bbc1c0..ac47cfddf2 100644 --- a/tests/Horizon/Unit/RedisQueueTest.php +++ b/tests/Horizon/Unit/RedisQueueTest.php @@ -4,12 +4,22 @@ namespace Hypervel\Tests\Horizon\Unit; +use Hypervel\Container\Container; +use Hypervel\Contracts\Events\Dispatcher as DispatcherContract; +use Hypervel\Contracts\Queue\Job; use Hypervel\Contracts\Redis\Factory; +use Hypervel\Events\Dispatcher; +use Hypervel\Horizon\Events\JobsMigrated; use Hypervel\Horizon\RedisQueue; +use Hypervel\Queue\LuaScripts; use Hypervel\Redis\RedisProxy; use Hypervel\Tests\Horizon\UnitTestCase; use Hypervel\Tests\Queue\Fixtures\IntegerQueueName; use Mockery as m; +use PHPUnit\Framework\Attributes\TestWith; +use RedisException; + +use function Hypervel\Coroutine\parallel; class RedisQueueTest extends UnitTestCase { @@ -28,4 +38,106 @@ public function testReadyNowReadsTheClusterSafeQueueKey(): void $this->assertSame(3, $queue->readyNow('critical')); $this->assertSame(4, $queue->readyNow(IntegerQueueName::Zero)); } + + #[TestWith(['critical'])] + #[TestWith(['{critical}'])] + public function testMigrationEventsReportTheQueueNameOnCluster(string $name): void + { + $key = 'queues:{critical}'; + $connection = m::mock(RedisProxy::class); + $connection->shouldReceive('isCluster')->andReturnTrue(); + $connection->shouldReceive('eval') + ->twice() + ->with(LuaScripts::migrateExpiredJobs(), 3, $key . ':delayed', $key, $key . ':notify', m::type('int'), m::type('int')) + ->andReturn([]); + $connection->shouldReceive('eval') + ->once() + ->with(LuaScripts::migrateExpiredJobs(), 3, $key . ':reserved', $key, $key . ':notify', m::type('int'), m::type('int')) + ->andReturn([]); + $connection->shouldReceive('eval') + ->once() + ->with(LuaScripts::pop(), 3, $key, $key . ':reserved', $key . ':notify', m::type('int')) + ->andReturn([]); + + $queues = []; + $queue = $this->queueRecordingMigrations($connection, $queues); + + $this->assertNull($queue->pop($name)); + + $queue->migrateExpiredJobs($key . ':delayed', $key); + + $this->assertSame([$name, $name, '{critical}'], $queues); + } + + public function testConcurrentPopsReportTheirOwnQueueNames(): void + { + $connection = m::mock(RedisProxy::class); + $connection->shouldReceive('isCluster')->andReturnTrue(); + $connection->shouldReceive('eval')->andReturnUsing(function (): array { + usleep(5000); + + return []; + }); + + $queues = []; + $queue = $this->queueRecordingMigrations($connection, $queues); + + parallel([ + fn (): ?Job => $queue->pop('critical'), + fn (): ?Job => $queue->pop('low'), + ]); + + $this->assertEqualsCanonicalizing(['critical', 'critical', 'low', 'low'], $queues); + } + + public function testFailedPopDoesNotLeakItsQueueNameIntoLaterMigrations(): void + { + $exception = new RedisException('Connection lost'); + $connection = m::mock(RedisProxy::class); + $connection->shouldReceive('isCluster')->andReturnFalse(); + $connection->shouldReceive('eval') + ->once() + ->with(LuaScripts::migrateExpiredJobs(), 3, 'queues:critical:delayed', 'queues:critical', 'queues:critical:notify', m::type('int'), m::type('int')) + ->andThrow($exception); + $connection->shouldReceive('eval') + ->once() + ->with(LuaScripts::migrateExpiredJobs(), 3, 'queues:low:delayed', 'queues:low', 'queues:low:notify', m::type('int'), m::type('int')) + ->andReturn([]); + + $queues = []; + $queue = $this->queueRecordingMigrations($connection, $queues); + + try { + $queue->pop('critical'); + $this->fail('The pop should have failed.'); + } catch (RedisException $caught) { + $this->assertSame($exception, $caught); + } + + $queue->migrateExpiredJobs('queues:low:delayed', 'queues:low'); + + $this->assertSame(['low'], $queues); + } + + /** + * Create a Horizon queue that records the queue name of each migration event. + */ + private function queueRecordingMigrations(RedisProxy $connection, array &$queues): RedisQueue + { + $redis = m::mock(Factory::class); + $redis->shouldReceive('connection')->with('default')->andReturn($connection); + + $container = new Container; + $events = new Dispatcher($container); + $container->instance(DispatcherContract::class, $events); + $events->listen(JobsMigrated::class, function (JobsMigrated $event) use (&$queues): void { + $queues[] = $event->queue; + }); + + $queue = new RedisQueue($redis, 'default', 'default'); + $queue->setContainer($container); + $queue->setConnectionName('redis'); + + return $queue; + } } diff --git a/tests/Integration/Horizon/Feature/QueueProcessingTest.php b/tests/Integration/Horizon/Feature/QueueProcessingTest.php index cb39c93aed..d8bc70c6d1 100644 --- a/tests/Integration/Horizon/Feature/QueueProcessingTest.php +++ b/tests/Integration/Horizon/Feature/QueueProcessingTest.php @@ -291,13 +291,16 @@ public function testStaleReservedJobsAreMarkedAsPendingAfterMigrating(): void Redis::connection('horizon')->hset($id, 'status', 'reserved'); $status = null; - Event::listen(JobsMigrated::class, function (JobsMigrated $event) use ($id, &$status): void { + $queue = null; + Event::listen(JobsMigrated::class, function (JobsMigrated $event) use ($id, &$status, &$queue): void { $status = Redis::connection('horizon')->hget($id, 'status'); + $queue = $event->queue; }); $this->work(); $this->assertSame('pending', $status); + $this->assertSame('default', $queue); } public function testInvalidRawPayloadIsTerminallyRemovedWithoutHorizonTelemetryFailure(): void diff --git a/tests/Passkeys/Feature/Actions/DeletePasskeyTest.php b/tests/Passkeys/Feature/Actions/DeletePasskeyTest.php index f72b32f7b4..ece261908d 100644 --- a/tests/Passkeys/Feature/Actions/DeletePasskeyTest.php +++ b/tests/Passkeys/Feature/Actions/DeletePasskeyTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Passkeys\Feature\Actions; +use Hypervel\Auth\GenericUser; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Passkeys\Actions\DeletePasskey; use Hypervel\Passkeys\Events\PasskeyDeleted; @@ -12,7 +13,6 @@ use Hypervel\Tests\Passkeys\Fixtures\User; use Hypervel\Tests\Passkeys\TestCase; use Mockery as m; -use Symfony\Component\HttpKernel\Exception\HttpException; class DeletePasskeyTest extends TestCase { @@ -58,60 +58,27 @@ public function testItDispatchesPasskeyDeletedEvent(): void ); } - public function testItRejectsDeletingAnotherUsersPasskey(): void + public function testItDeletesAnotherUsersPasskeyForAnActorThatDoesNotOwnPasskeys(): void { - $user = User::create([ - 'name' => 'John Doe', - 'email' => 'john@example.com', - ]); - $otherUser = User::create([ - 'name' => 'Jane Doe', - 'email' => 'jane@example.com', - ]); - $passkey = $this->createPasskeyForUser($user, 'credential-other-owner'); - - try { - app(DeletePasskey::class)($otherUser, $passkey); - } catch (HttpException $exception) { - $this->assertSame(403, $exception->getStatusCode()); - $this->assertDatabaseHas('passkeys', [ - 'id' => $passkey->getKey(), - ]); - - return; - } - - $this->fail('Expected deleting another user\'s passkey to fail.'); - } + Event::fake([PasskeyDeleted::class]); - public function testItRejectsDeletingAPasskeyForTheSameKeyOnADifferentOwnerMorphClass(): void - { $user = User::create([ 'name' => 'John Doe', 'email' => 'john@example.com', ]); - $sameKeyDifferentMorphUser = AlternatePasskeyUser::create([ - 'name' => 'Jane Doe', - 'email' => 'jane@example.com', - ]); - $passkey = $this->createPasskeyForUser($user, 'credential-other-morph'); + $passkey = $this->createPasskeyForUser($user, 'credential-admin-delete'); + $administrator = new GenericUser(['id' => 99]); - $sameKeyDifferentMorphUser->forceFill([ - $sameKeyDifferentMorphUser->getKeyName() => $user->getKey(), - ]); + app(DeletePasskey::class)($administrator, $passkey); - try { - app(DeletePasskey::class)($sameKeyDifferentMorphUser, $passkey); - } catch (HttpException $exception) { - $this->assertSame(403, $exception->getStatusCode()); - $this->assertDatabaseHas('passkeys', [ - 'id' => $passkey->getKey(), - ]); - - return; - } - - $this->fail('Expected deleting a passkey for a different owner morph class to fail.'); + $this->assertDatabaseMissing('passkeys', [ + 'id' => $passkey->getKey(), + ]); + Event::assertDispatched( + PasskeyDeleted::class, + static fn (PasskeyDeleted $event): bool => $event->user === $administrator + && $event->passkey->is($passkey), + ); } public function testItDoesNotDispatchPasskeyDeletedEventWithoutListeners(): void @@ -151,8 +118,3 @@ private function createPasskeyForUser(User $user, string $credentialId): Passkey ]); } } - -class AlternatePasskeyUser extends User -{ - protected ?string $table = 'users'; -} diff --git a/tests/Passkeys/Feature/Actions/VerifyPasskeyTest.php b/tests/Passkeys/Feature/Actions/VerifyPasskeyTest.php index 262818b434..13f077d74c 100644 --- a/tests/Passkeys/Feature/Actions/VerifyPasskeyTest.php +++ b/tests/Passkeys/Feature/Actions/VerifyPasskeyTest.php @@ -4,10 +4,7 @@ namespace Hypervel\Tests\Passkeys\Feature\Actions; -use Closure; use Hypervel\Contracts\Events\Dispatcher; -use Hypervel\Database\ConnectionInterface; -use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Passkeys\Actions\VerifyPasskey; use Hypervel\Passkeys\Events\PasskeyVerified; use Hypervel\Passkeys\Exceptions\InvalidPasskeyException; @@ -16,6 +13,7 @@ use Hypervel\Passkeys\Support\WebAuthn; use Hypervel\Support\Facades\DB; use Hypervel\Support\Facades\Event; +use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Tests\Passkeys\Fixtures\User; use Hypervel\Tests\Passkeys\Fixtures\WebAuthnFixtures; use Hypervel\Tests\Passkeys\TestCase; @@ -69,9 +67,7 @@ public function testItVerifiesAPasskeyAndReturnsIt(): void $updatedSource = $this->createCredentialSource($userHandle, $credentialId, counter: 6); - $action = m::mock(VerifyPasskey::class, [ - app(ConnectionResolverInterface::class), - ]) + $action = m::mock(VerifyPasskey::class) ->makePartial() ->shouldAllowMockingProtectedMethods() ->shouldReceive('validate') @@ -176,9 +172,7 @@ public function testItThrowsExceptionWhenPasskeyDoesNotBelongToExpectedUser(): v response: m::mock(AuthenticatorAssertionResponse::class), ); - $action = m::mock(VerifyPasskey::class, [ - app(ConnectionResolverInterface::class), - ]) + $action = m::mock(VerifyPasskey::class) ->makePartial() ->shouldAllowMockingProtectedMethods() ->shouldReceive('validate') @@ -315,7 +309,7 @@ public function testItUsesTheRelyingPartyIdStoredInVerificationOptions(): void rpId: 'registered.example.com', ); - $action = new ExposesVerifyPasskeyHost(app(ConnectionResolverInterface::class)); + $action = new ExposesVerifyPasskeyHost; $this->assertSame('registered.example.com', $action->host($options)); } @@ -326,7 +320,7 @@ public function testItThrowsWhenVerificationOptionsHaveNoRelyingPartyId(): void challenge: random_bytes(32), ); - $action = new ExposesVerifyPasskeyHost(app(ConnectionResolverInterface::class)); + $action = new ExposesVerifyPasskeyHost; $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Passkey verification options must contain a relying party ID.'); @@ -334,6 +328,11 @@ public function testItThrowsWhenVerificationOptionsHaveNoRelyingPartyId(): void $action->host($options); } + #[WithConfig('database.connections.passkeys', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'foreign_key_constraints' => false, + ])] public function testItUsesTheConfiguredPasskeyModelConnectionForVerificationTransactions(): void { Passkeys::usePasskeyModel(CustomConnectionPasskey::class); @@ -352,14 +351,6 @@ public function testItUsesTheConfiguredPasskeyModelConnectionForVerificationTran 'credential' => ['id' => 'credential-connection'], ]); - $database = m::mock(ConnectionResolverInterface::class); - $connection = m::mock(ConnectionInterface::class); - $database->shouldReceive('connection')->once()->with('passkeys')->andReturn($connection); - $connection->shouldReceive('transaction') - ->once() - ->with(m::type(Closure::class)) - ->andReturnUsing(static fn (Closure $callback): Passkey => $callback()); - $events = m::mock(Dispatcher::class)->shouldIgnoreMissing(); $events->shouldReceive('hasListeners')->withAnyArgs()->andReturnFalse()->byDefault(); $events->shouldReceive('hasListeners')->once()->with(PasskeyVerified::class)->andReturnFalse(); @@ -380,13 +371,13 @@ public function testItUsesTheConfiguredPasskeyModelConnectionForVerificationTran ); $verifier = new ConnectionAwareVerifyPasskey( - $database, $passkey, $this->createStub(AuthenticatorAssertionResponse::class), ); $this->assertSame($passkey, $verifier($credential, $options, $user)); $this->assertTrue($verifier->receivedLockedLookup); + $this->assertSame(1, $verifier->lookupTransactionLevel); } /** @@ -436,12 +427,12 @@ class ConnectionAwareVerifyPasskey extends VerifyPasskey { public bool $receivedLockedLookup = false; + public ?int $lookupTransactionLevel = null; + public function __construct( - ConnectionResolverInterface $database, private readonly Passkey $passkey, private readonly AuthenticatorAssertionResponse $response, ) { - parent::__construct($database); } /** @@ -455,9 +446,10 @@ protected function getResponse(PublicKeyCredential $credential): AuthenticatorAs /** * Get the passkey by credential ID. */ - public function getPasskey(PublicKeyCredential $credential, bool $lock = false, ?string $ownerType = null): Passkey + public function getPasskey(PublicKeyCredential $credential, bool $lock = false): Passkey { $this->receivedLockedLookup = $lock; + $this->lookupTransactionLevel = DB::connection('passkeys')->transactionLevel(); return $this->passkey; } diff --git a/tests/Passkeys/Feature/Controllers/PasskeyConfirmationTest.php b/tests/Passkeys/Feature/Controllers/PasskeyConfirmationTest.php index a0ba752da2..ebf10f2bcc 100644 --- a/tests/Passkeys/Feature/Controllers/PasskeyConfirmationTest.php +++ b/tests/Passkeys/Feature/Controllers/PasskeyConfirmationTest.php @@ -48,7 +48,7 @@ public function testItStoresConfirmationOptionsInSession(): void ->getJson('/passkeys/confirm/options') ->assertOk(); - $this->assertNotNull(session('passkey.confirmation_options')); + $this->assertNotNull(session('passkey.verification_options_web')); } public function testItRequiresAuthenticationForConfirmationOptions(): void @@ -84,7 +84,7 @@ public function testItReturnsValidationErrorWhenPasskeyConfirmationIsInvalid(): ->getMock()); $this->actingAs($user) - ->withSession(['passkey.confirmation_options' => WebAuthn::toJson($this->createRequestOptions())]) + ->withSession(['passkey.verification_options_web' => WebAuthn::toJson($this->createRequestOptions())]) ->postJson('/passkeys/confirm', [ 'credential' => $this->createAssertionCredential(), ]) @@ -116,7 +116,7 @@ public function testItMarksThePasswordAsConfirmedWhenPasskeyConfirmationSucceeds ->getMock()); $this->actingAs($user) - ->withSession(['passkey.confirmation_options' => WebAuthn::toJson($this->createRequestOptions())]) + ->withSession(['passkey.verification_options_web' => WebAuthn::toJson($this->createRequestOptions())]) ->postJson('/passkeys/confirm', [ 'credential' => $this->createAssertionCredential(), ]) diff --git a/tests/Passkeys/Feature/Controllers/PasskeyLoginControllerTest.php b/tests/Passkeys/Feature/Controllers/PasskeyLoginControllerTest.php index eeb7d8b94c..4082c8ea86 100644 --- a/tests/Passkeys/Feature/Controllers/PasskeyLoginControllerTest.php +++ b/tests/Passkeys/Feature/Controllers/PasskeyLoginControllerTest.php @@ -38,7 +38,7 @@ public function testItDoesNotLogInWhenCustomSignInAuthorizationCallbackReturnsFa Passkeys::authorizeLoginUsing(static fn (): bool => false); - $this->withSession(['passkey.login_options' => WebAuthn::toJson($this->createRequestOptions())]) + $this->withSession(['passkey.verification_options_web' => WebAuthn::toJson($this->createRequestOptions())]) ->postJson('/passkeys/login', ['credential' => $this->createAssertionCredential()]) ->assertUnprocessable(); @@ -70,7 +70,7 @@ public function testItReturnsTheCustomSignInAuthorizationValidationMessage(): vo ]); }); - $this->withSession(['passkey.login_options' => WebAuthn::toJson($this->createRequestOptions())]) + $this->withSession(['passkey.verification_options_web' => WebAuthn::toJson($this->createRequestOptions())]) ->postJson('/passkeys/login', ['credential' => $this->createAssertionCredential()]) ->assertUnprocessable() ->assertJsonValidationErrors(['credential' => 'This account has been suspended.']); @@ -99,7 +99,7 @@ public function testItLogsInWhenCustomSignInAuthorizationCallbackReturnsTrue(): Passkeys::authorizeLoginUsing(static fn (): bool => true); - $this->withSession(['passkey.login_options' => WebAuthn::toJson($this->createRequestOptions())]) + $this->withSession(['passkey.verification_options_web' => WebAuthn::toJson($this->createRequestOptions())]) ->postJson('/passkeys/login', [ 'credential' => $this->createAssertionCredential(), 'remember' => true, diff --git a/tests/Passkeys/Feature/Controllers/PasskeyLoginTest.php b/tests/Passkeys/Feature/Controllers/PasskeyLoginTest.php index 38dde65980..0b9fbcfd4b 100644 --- a/tests/Passkeys/Feature/Controllers/PasskeyLoginTest.php +++ b/tests/Passkeys/Feature/Controllers/PasskeyLoginTest.php @@ -34,7 +34,7 @@ public function testItStoresLoginOptionsInSession(): void { $this->getJson('/passkeys/login/options')->assertOk(); - $this->assertNotNull(session('passkey.login_options')); + $this->assertNotNull(session('passkey.verification_options_web')); } public function testItReturnsValidationErrorWhenPasskeyIsInvalid(): void @@ -45,7 +45,7 @@ public function testItReturnsValidationErrorWhenPasskeyIsInvalid(): void ->andThrow(InvalidPasskeyException::make('Unable to verify passkey. Please try again.')) ->getMock()); - $this->withSession(['passkey.login_options' => WebAuthn::toJson($this->createRequestOptions())]) + $this->withSession(['passkey.verification_options_web' => WebAuthn::toJson($this->createRequestOptions())]) ->postJson('/passkeys/login', [ 'credential' => $this->createAssertionCredential(), ]) @@ -57,7 +57,7 @@ public function testItReturnsValidationErrorWhenPasskeyIsInvalid(): void public function testItReturnsValidationErrorWhenCredentialFormatIsInvalid(): void { - $this->withSession(['passkey.login_options' => WebAuthn::toJson($this->createRequestOptions())]) + $this->withSession(['passkey.verification_options_web' => WebAuthn::toJson($this->createRequestOptions())]) ->postJson('/passkeys/login', [ 'credential' => [ 'id' => 'dGVzdC1pZA', diff --git a/tests/Passkeys/Feature/Controllers/PasskeyRegistrationTest.php b/tests/Passkeys/Feature/Controllers/PasskeyRegistrationTest.php index f9b3c6f2ec..a92259b1fb 100644 --- a/tests/Passkeys/Feature/Controllers/PasskeyRegistrationTest.php +++ b/tests/Passkeys/Feature/Controllers/PasskeyRegistrationTest.php @@ -84,7 +84,7 @@ public function testItStoresRegistrationOptionsInSession(): void ->getJson('/user/passkeys/options') ->assertOk(); - $this->assertNotNull(session('passkey.registration_options')); + $this->assertNotNull(session('passkey.registration_options_web')); } public function testRegistrationOptionsRemainInSessionWhenTheAuthenticatedUserChanges(): void @@ -103,13 +103,13 @@ public function testRegistrationOptionsRemainInSessionWhenTheAuthenticatedUserCh ->getJson('/user/passkeys/options') ->assertOk(); - $registrationOptions = session('passkey.registration_options'); + $registrationOptions = session('passkey.registration_options_web'); Passkeys::guard()->logout(); Passkeys::guard()->login($secondUser); $this->assertTrue(Passkeys::guard()->user()?->is($secondUser)); - $this->assertSame($registrationOptions, session('passkey.registration_options')); + $this->assertSame($registrationOptions, session('passkey.registration_options_web')); } public function testItRequiresPasswordConfirmationForRegistrationOptions(): void @@ -162,7 +162,7 @@ public function testItReturnsValidationErrorWhenPasskeyIsInvalid(): void $this->actingAs($user) ->withSession(['auth.password_confirmed_at_web' => time()]) - ->withSession(['passkey.registration_options' => WebAuthn::toJson($this->createRegistrationOptions($user))]) + ->withSession(['passkey.registration_options_web' => WebAuthn::toJson($this->createRegistrationOptions($user))]) ->postJson('/user/passkeys', [ 'name' => 'My Passkey', 'credential' => $this->createRegistrationCredential(), diff --git a/tests/Passkeys/Feature/PasskeyAuthenticatableTest.php b/tests/Passkeys/Feature/PasskeyAuthenticatableTest.php index 3f0eb23194..fdf535ee08 100644 --- a/tests/Passkeys/Feature/PasskeyAuthenticatableTest.php +++ b/tests/Passkeys/Feature/PasskeyAuthenticatableTest.php @@ -79,7 +79,7 @@ public function testItReturnsTheSameHandleAcrossFreshModelInstances(): void $handle = $user->getPasskeyUserHandle(); $this->assertSame( - hash_hmac('sha256', 'users|' . $user->getKey(), 'test-secret', binary: true), + hash_hmac('sha256', User::class . '|users|' . $user->getKey(), 'test-secret', binary: true), $handle, ); $this->assertNotSame((string) $user->getKey(), $handle); @@ -146,6 +146,20 @@ public function testItIncludesTheModelTableWhenDerivingUserHandles(): void $this->assertNotSame($user->getPasskeyUserHandle(), $admin->getPasskeyUserHandle()); } + public function testItIncludesTheOwnerMorphClassWhenDerivingUserHandles(): void + { + config(['passkeys.user_handle_secret' => 'test-secret']); + + $user = User::create([ + 'name' => 'Alex Müller', + 'email' => 'alex@example.com', + ]); + + $sameRowOwner = SameTableUser::findOrFail($user->getKey()); + + $this->assertNotSame($user->getPasskeyUserHandle(), $sameRowOwner->getPasskeyUserHandle()); + } + public function testDeletingOwnerDeletesRelatedPasskeys(): void { $user = User::create([ @@ -291,6 +305,11 @@ class AdminUser extends Authenticatable implements PasskeyUser protected array $guarded = []; } +class SameTableUser extends User +{ + protected ?string $table = 'users'; +} + class SoftDeletingPasskeyUser extends Authenticatable implements PasskeyUser { use PasskeyAuthenticatable; diff --git a/tests/Passkeys/PackageMetadataTest.php b/tests/Passkeys/PackageMetadataTest.php index 9abbc5b90d..306802889e 100644 --- a/tests/Passkeys/PackageMetadataTest.php +++ b/tests/Passkeys/PackageMetadataTest.php @@ -52,7 +52,6 @@ public function testDirectDependenciesMatchTheMonorepoConstraints(): void 'paragonie/constant_time_encoding', 'symfony/console', 'symfony/http-foundation', - 'symfony/http-kernel', 'symfony/serializer', 'web-auth/cose-lib', 'web-auth/webauthn-lib', @@ -67,7 +66,6 @@ public function testDirectDependenciesMatchTheMonorepoConstraints(): void 'paragonie/constant_time_encoding', 'symfony/console', 'symfony/http-foundation', - 'symfony/http-kernel', 'symfony/serializer', 'web-auth/cose-lib', 'web-auth/webauthn-lib', diff --git a/tests/Passkeys/PasskeyRegistrationResponseTest.php b/tests/Passkeys/PasskeyRegistrationResponseTest.php index 2c1a492871..ca4e0bc416 100644 --- a/tests/Passkeys/PasskeyRegistrationResponseTest.php +++ b/tests/Passkeys/PasskeyRegistrationResponseTest.php @@ -5,12 +5,18 @@ namespace Hypervel\Tests\Passkeys; use Hypervel\Http\Request; +use Hypervel\Passkeys\Contracts\PasskeyRegistrationResponse as PasskeyRegistrationResponseContract; use Hypervel\Passkeys\Http\Responses\PasskeyRegistrationResponse; use Hypervel\Passkeys\Passkey; +use PHPUnit\Framework\Attributes\DataProvider; class PasskeyRegistrationResponseTest extends TestCase { - public function testWithPasskeyReturnsCloneWithoutMutatingOriginalResponse(): void + /** + * @param class-string $abstract + */ + #[DataProvider('registrationResponseAbstracts')] + public function testEachResolvedResponseRetainsItsOwnPasskey(string $abstract): void { $firstPasskey = new Passkey([ 'name' => 'First key', @@ -22,12 +28,11 @@ public function testWithPasskeyReturnsCloneWithoutMutatingOriginalResponse(): vo ]); $secondPasskey->id = 2; - $response = new PasskeyRegistrationResponse; - $firstResponse = $response->withPasskey($firstPasskey); - $secondResponse = $response->withPasskey($secondPasskey); + $firstResponse = $this->app->make($abstract); + $secondResponse = $this->app->make($abstract); - $this->assertNotSame($response, $firstResponse); - $this->assertNotSame($response, $secondResponse); + $firstResponse->withPasskey($firstPasskey); + $secondResponse->withPasskey($secondPasskey); $this->assertSame( ['status' => 'passkey-registered', 'id' => '1', 'name' => 'First key'], @@ -39,4 +44,17 @@ public function testWithPasskeyReturnsCloneWithoutMutatingOriginalResponse(): vo json_decode($secondResponse->toResponse(Request::create('/', server: ['HTTP_ACCEPT' => 'application/json']))->getContent(), true) ); } + + /** + * Get the container keys that resolve the registration response. + * + * @return array}> + */ + public static function registrationResponseAbstracts(): array + { + return [ + 'contract' => [PasskeyRegistrationResponseContract::class], + 'concrete response' => [PasskeyRegistrationResponse::class], + ]; + } } diff --git a/tests/Passkeys/PasskeysGuardTest.php b/tests/Passkeys/PasskeysGuardTest.php index 9cd3e6ba09..730d2fb192 100644 --- a/tests/Passkeys/PasskeysGuardTest.php +++ b/tests/Passkeys/PasskeysGuardTest.php @@ -10,15 +10,23 @@ use Hypervel\Database\Schema\Blueprint; use Hypervel\Passkeys\Actions\VerifyPasskey; use Hypervel\Passkeys\Exceptions\InvalidPasskeyException; +use Hypervel\Passkeys\Http\Controllers\PasskeyConfirmationController; +use Hypervel\Passkeys\Http\Controllers\PasskeyLoginController; +use Hypervel\Passkeys\Http\Controllers\PasskeyRegistrationController; +use Hypervel\Passkeys\Http\Requests\PasskeyRegistrationRequest; +use Hypervel\Passkeys\Http\Requests\PasskeyVerificationRequest; use Hypervel\Passkeys\Passkey; use Hypervel\Passkeys\Passkeys; +use Hypervel\Passkeys\Support\WebAuthn; +use Hypervel\Support\Facades\Route; use Hypervel\Support\Facades\Schema; use Hypervel\Tests\Passkeys\Fixtures\Admin; use Hypervel\Tests\Passkeys\Fixtures\User; use ParagonIE\ConstantTime\Base64UrlSafe; -use ReflectionMethod; -use Webauthn\AuthenticatorResponse; +use PHPUnit\Framework\Attributes\DataProvider; +use Webauthn\AuthenticatorAssertionResponse; use Webauthn\PublicKeyCredential; +use Webauthn\PublicKeyCredentialRequestOptions; class PasskeysGuardTest extends TestCase { @@ -36,10 +44,10 @@ public function testPasskeysGuardFollowsCurrentDefaultGuardSelectedByShouldUse() $this->assertInstanceOf(StatefulGuard::class, Passkeys::guard()); } - public function testSelectedGuardProviderScopesPasswordlessPasskeyLookup(): void + #[DataProvider('ownerTypesOutsideTheSelectedProvider')] + public function testSelectedGuardProviderScopesPasswordlessPasskeyVerification(string $ownerType): void { $this->configureAdminGuard(); - $this->createAdminsTable(); /** @var AuthFactory $auth */ $auth = $this->app->make(AuthFactory::class); @@ -49,37 +57,103 @@ public function testSelectedGuardProviderScopesPasswordlessPasskeyLookup(): void 'name' => 'User', 'email' => 'user@example.com', ]); - $admin = Admin::create([ - 'name' => 'Admin', - 'email' => 'admin@example.com', - ]); $rawCredentialId = random_bytes(32); $credentialId = Base64UrlSafe::encodeUnpadded($rawCredentialId); - /** @var Passkey $passkey */ - $passkey = $user->passkeys()->create([ + (new Passkey)->forceFill([ + 'user_type' => $ownerType, + 'user_id' => $user->getKey(), 'name' => 'User key', 'credential_id' => $credentialId, 'credential' => ['id' => $credentialId], - ]); + ])->save(); $credential = PublicKeyCredential::create( 'public-key', $rawCredentialId, - $this->createStub(AuthenticatorResponse::class), + $this->createStub(AuthenticatorAssertionResponse::class), ); - $verifier = new VerifyPasskey($this->app->make('db')); - $selectedOwnerMorphClass = $this->selectedOwnerMorphClass($verifier); - - $this->assertSame($admin->getMorphClass(), $selectedOwnerMorphClass); - $this->assertNotSame($passkey->user_type, $selectedOwnerMorphClass); - $this->expectException(InvalidPasskeyException::class); $this->expectExceptionMessage('Passkey not recognized. It may have been removed from your account.'); - $verifier->getPasskey($credential, ownerType: $selectedOwnerMorphClass); + app(VerifyPasskey::class)($credential, PublicKeyCredentialRequestOptions::create( + challenge: random_bytes(32), + rpId: 'localhost', + )); + } + + /** + * Get stored owner types that the admin guard provider does not own. + * + * @return array + */ + public static function ownerTypesOutsideTheSelectedProvider(): array + { + return [ + 'another provider model' => [User::class], + 'unresolvable owner type' => ['Missing\PasskeyOwner'], + ]; + } + + public function testLoginAndConfirmationOptionsArePendingSeparatelyForEachGuard(): void + { + $this->configureAdminGuard(); + + Route::middleware(['web', 'guest:admin']) + ->get('/admin/passkeys/login/options', [PasskeyLoginController::class, 'index']); + Route::middleware(['web', 'auth:web']) + ->get('/account/passkeys/confirm/options', [PasskeyConfirmationController::class, 'index']); + + $user = User::create([ + 'name' => 'User', + 'email' => 'user@example.com', + ]); + + $adminLogin = $this->actingAs($user, 'web') + ->getJson('/admin/passkeys/login/options') + ->assertOk(); + + $userConfirmation = $this->getJson('/account/passkeys/confirm/options') + ->assertOk(); + + $this->assertSame($adminLogin->json('options.challenge'), $this->pendingVerificationChallenge('admin')); + $this->assertSame($userConfirmation->json('options.challenge'), $this->pendingVerificationChallenge('web')); + } + + public function testRegistrationOptionsArePendingSeparatelyForEachGuard(): void + { + $this->configureAdminGuard(); + + Schema::create('admins', function (Blueprint $table): void { + $table->id(); + $table->string('name'); + $table->string('email'); + $table->timestamps(); + }); + + Route::middleware(['web', 'auth:web']) + ->get('/account/passkeys/options', [PasskeyRegistrationController::class, 'index']); + Route::middleware(['web', 'auth:admin']) + ->get('/admin/passkeys/options', [PasskeyRegistrationController::class, 'index']); + + $user = User::create([ + 'name' => 'User', + 'email' => 'user@example.com', + ]); + $admin = Admin::create([ + 'name' => 'Admin', + 'email' => 'admin@example.com', + ]); + + $this->actingAs($user, 'web')->actingAs($admin, 'admin'); + + $userRegistration = $this->getJson('/account/passkeys/options')->assertOk(); + $adminRegistration = $this->getJson('/admin/passkeys/options')->assertOk(); + + $this->assertSame($userRegistration->json('options.challenge'), $this->pendingRegistrationChallenge('web')); + $this->assertSame($adminRegistration->json('options.challenge'), $this->pendingRegistrationChallenge('admin')); } /** @@ -110,26 +184,28 @@ private function configureAdminGuard(): void } /** - * Create the admins table fixture. + * Get the challenge from the guard's pending verification options. */ - private function createAdminsTable(): void + private function pendingVerificationChallenge(string $guard): string { - Schema::create('admins', function (Blueprint $table): void { - $table->id(); - $table->string('name'); - $table->string('email')->unique(); - $table->rememberToken(); - $table->timestamps(); - }); + $this->app->make(AuthFactory::class)->shouldUse($guard); + + $request = PasskeyVerificationRequest::create('/'); + $request->setHypervelSession($this->app->make('session.store')); + + return WebAuthn::toBrowserArray($request->verificationOptions())['challenge']; } /** - * Get the owner morph class for the selected guard. + * Get the challenge from the guard's pending registration options. */ - private function selectedOwnerMorphClass(VerifyPasskey $verifier): string + private function pendingRegistrationChallenge(string $guard): string { - $method = new ReflectionMethod($verifier, 'ownerMorphClassForGuard'); + $this->app->make(AuthFactory::class)->shouldUse($guard); + + $request = PasskeyRegistrationRequest::create('/'); + $request->setHypervelSession($this->app->make('session.store')); - return $method->invoke($verifier, Passkeys::guard()); + return WebAuthn::toBrowserArray($request->registrationOptions())['challenge']; } } diff --git a/tests/Passkeys/PasskeysRouteTest.php b/tests/Passkeys/PasskeysRouteTest.php index bbbb38b0d1..1f9bd389c3 100644 --- a/tests/Passkeys/PasskeysRouteTest.php +++ b/tests/Passkeys/PasskeysRouteTest.php @@ -56,9 +56,9 @@ public function testNullGuardConfigDoesNotAddGuardSelectionMiddleware(): void } #[WithConfig('passkeys.throttle', null)] - public function testNullThrottleOmitsThrottleMiddlewareFromLoginAndManagementRoutes(): void + public function testNullThrottleOmitsThrottleMiddlewareFromLoginAndRegistrationRoutes(): void { - foreach (['passkey.login', 'passkey.registration-options', 'passkey.destroy'] as $routeName) { + foreach (['passkey.login', 'passkey.registration-options'] as $routeName) { $route = Route::getRoutes()->getByName($routeName); $this->assertNotNull($route); @@ -70,7 +70,7 @@ public function testNullThrottleOmitsThrottleMiddlewareFromLoginAndManagementRou } } - public function testOmittedThrottleUsesDefaultMiddlewareOnLoginAndManagementRoutes(): void + public function testOmittedThrottleUsesDefaultMiddlewareOnLoginAndRegistrationRoutes(): void { $config = config()->array('passkeys'); unset($config['throttle']); @@ -78,7 +78,7 @@ public function testOmittedThrottleUsesDefaultMiddlewareOnLoginAndManagementRout require dirname(__DIR__, 2) . '/src/passkeys/routes/routes.php'; - foreach (['passkey.login', 'passkey.registration-options', 'passkey.destroy'] as $routeName) { + foreach (['passkey.login', 'passkey.registration-options'] as $routeName) { $route = Route::getRoutes()->getByName($routeName); $this->assertNotNull($route); @@ -87,11 +87,19 @@ public function testOmittedThrottleUsesDefaultMiddlewareOnLoginAndManagementRout } #[WithConfig('passkeys.throttle', 'throttle:12,1')] - public function testConfiguredThrottleAppliesToPasskeyDeletion(): void + public function testConfiguredThrottleDoesNotApplyToPasskeyDeletion(): void { + $this->assertContains('throttle:12,1', Route::getRoutes()->getByName('passkey.store')->middleware()); + $route = Route::getRoutes()->getByName('passkey.destroy'); $this->assertNotNull($route); - $this->assertContains('throttle:12,1', $route->middleware()); + $this->assertContains('auth', $route->middleware()); + $this->assertContains('password.confirm', $route->middleware()); + $this->assertFalse(array_any( + $route->middleware(), + static fn (mixed $middleware): bool => is_string($middleware) + && str_starts_with($middleware, 'throttle:'), + )); } } diff --git a/tests/Validation/ValidationValidatorTest.php b/tests/Validation/ValidationValidatorTest.php index 5fd1983a32..885333525a 100755 --- a/tests/Validation/ValidationValidatorTest.php +++ b/tests/Validation/ValidationValidatorTest.php @@ -949,7 +949,9 @@ public function testCapitalizedDisplayableValuesAreReplaced() #[TestWith(['declined_if', ['foo' => 'yes', 'bar' => 'aAa']])] #[TestWith(['missing_if', ['foo' => 'yes', 'bar' => 'aAa']])] + #[TestWith(['missing_unless', ['foo' => 'yes', 'bar' => 'bBb']])] #[TestWith(['present_if', ['bar' => 'aAa']])] + #[TestWith(['present_unless', ['bar' => 'bBb']])] #[TestWith(['required_if', ['bar' => 'aAa']])] public function testConditionalRulePlaceholdersPreserveCasingVariants(string $rule, array $data): void { @@ -1000,6 +1002,9 @@ public function testProhibitedUnlessPlaceholdersPreserveCasingVariants(): void #[TestWith(['ends_with', 'other'])] #[TestWith(['doesnt_end_with', 'tAylor'])] #[TestWith(['doesnt_start_with', 'sVen'])] + #[TestWith(['extensions', 'file'])] + #[TestWith(['mimes', 'file'])] + #[TestWith(['mimetypes', 'file'])] public function testValueListRulePlaceholdersPreserveCasingVariants(string $rule, array|string $value): void { $validator = new Validator( diff --git a/types/Database/Eloquent/Relations.php b/types/Database/Eloquent/Relations.php index 33cc82a098..9c19c21e4c 100644 --- a/types/Database/Eloquent/Relations.php +++ b/types/Database/Eloquent/Relations.php @@ -181,6 +181,11 @@ public function posts(): HasMany $hasMany = $this->hasMany(Post::class); assertType('Hypervel\Database\Eloquent\Relations\HasMany', $hasMany); + assertType( + 'Hypervel\Database\Eloquent\Relations\HasManyThrough', + $this->through($hasMany)->has(fn ($post) => $post->comments()), + ); + return $hasMany; } @@ -234,7 +239,7 @@ public function car(): HasOneThrough $through = $this->through('mechanic'); assertType( - 'Hypervel\Database\Eloquent\PendingHasThroughRelationship', + 'Hypervel\Database\Eloquent\PendingHasThroughRelationship>', $through, ); assertType(