diff --git a/docs/1-essentials/01-routing.md b/docs/1-essentials/01-routing.md index c487b8c93..608017e98 100644 --- a/docs/1-essentials/01-routing.md +++ b/docs/1-essentials/01-routing.md @@ -162,10 +162,10 @@ public function docsRedirect(string $path): Redirect ## Generating URIs -Tempest provides a `\Tempest\uri` function that can be used to generate an URI to a controller method. This function accepts the FQCN of the controller or a callable to a method as its first argument, and named parameters as [the rest of its arguments](https://www.php.net/manual/en/functions.arguments.php#functions.variable-arg-list). +Tempest provides a `\Tempest\uri` function that can be used to generate a URI to a controller method. This function accepts the FQCN of the controller or a callable to a method as its first argument, and named parameters as [the rest of its arguments](https://www.php.net/manual/en/functions.arguments.php#functions.variable-arg-list). ```php -use function Tempest\uri; +use function Tempest\Router\uri; // Invokable classes can be referenced directly: uri(HomeController::class); @@ -181,15 +181,62 @@ uri([AircraftController::class, 'show'], id: $aircraft->id); ``` :::info -Note that Tempest does not have named routes, and currently doesn't plan on adding them. However, if you have an argument for them, feel free to hop on our [Discord server](/discord){:ssg-ignore="true"} to discuss it. +URI-related methods are also available by injecting the {b`Tempest\Router\UriGenerator`} class into your controller. ::: -## Matching the current URI +### Signed URIs -To determine whether the current request matches a specific controller action, Tempest provides the `\Tempest\is_current_uri` function. This function accepts the same arguments as `uri`, and returns a boolean. +A signed URI may be used to ensure that the URI was not modified after it was created. This is useful for implementing login links, or other endpoints that need protection against tampering. + +To create a signed URI, you may use the `signed_uri` function. This function accepts the same arguments as `uri`, and returns the URI with a `signature` parameter: + +```php +use function Tempest\Router\signed_uri; + +signed_uri( + action: [MailingListController::class, 'unsubscribe'], + email: $email +); +``` + +Alternatively, you may use `temporary_signed_uri` to provide a duration after which the signed URI will expire, providing an extra layer of security. + +```php +use function Tempest\Router\temporary_signed_uri; + +temporary_signed_uri( + action: PasswordlessAuthenticationController::class, + duration: Duration::minutes(10), + userId: $userId +); +``` + +To ensure the validity of a signed URL, you should call the `hasValidSignature` method on the {`Tempest\Router\UriGenerator`} class. + +```php +final class PasswordlessAuthenticationController +{ + public function __construct( + private readonly UriGenerator $uri, + ) {} + + public function __invoke(Request $request): Response + { + if (! $this->uri->hasValidSignature($request)) { + return new Invalid(); + } + + // ... + } +} +``` + +### Matching the current URI + +To determine whether the current request matches a specific controller action, Tempest provides the `is_current_uri` function. This function accepts the same arguments as `uri`, and returns a boolean. ```php -use function Tempest\is_current_uri; +use function Tempest\Router\is_current_uri; // Current URI is: /aircraft/1 @@ -243,7 +290,7 @@ Once you have created a request class, you may simply inject it into a controlle use Tempest\Router\Post; use Tempest\Http\Responses\Redirect; use function Tempest\map; -use function Tempest\uri; +use function Tempest\Router\uri; final readonly class AirportController { diff --git a/docs/4-internals/03-view-spec.md b/docs/4-internals/03-view-spec.md index b48d7af16..fd857b541 100644 --- a/docs/4-internals/03-view-spec.md +++ b/docs/4-internals/03-view-spec.md @@ -103,7 +103,7 @@ Tempest will merge all imports at the top of the compiled view, meaning that eac ```html {{ uri([PostController::class, 'show'], post: $post->id) }} @@ -608,7 +608,7 @@ Referencing a symbol within a view will automatically import it at the top of th ```html {{ uri([PostController::class, 'show'], post: $post->id) }} diff --git a/packages/router/src/GenericRouter.php b/packages/router/src/GenericRouter.php index dd1e6ac87..c242d5498 100644 --- a/packages/router/src/GenericRouter.php +++ b/packages/router/src/GenericRouter.php @@ -4,7 +4,6 @@ namespace Tempest\Router; -use BackedEnum; use Psr\Http\Message\ServerRequestInterface as PsrRequest; use ReflectionClass; use Tempest\Container\Container; @@ -14,17 +13,12 @@ use Tempest\Http\Request; use Tempest\Http\Response; use Tempest\Http\Responses\Ok; -use Tempest\Reflection\ClassReflector; use Tempest\Router\Exceptions\ControllerActionHadNoReturn; -use Tempest\Router\Exceptions\ControllerMethodHadNoRouteAttribute; use Tempest\Router\Exceptions\MatchedRouteCouldNotBeResolved; -use Tempest\Router\Routing\Construction\DiscoveredRoute; use Tempest\Router\Routing\Matching\RouteMatcher; use Tempest\View\View; use function Tempest\map; -use function Tempest\Support\Regex\replace; -use function Tempest\Support\str; final readonly class GenericRouter implements Router { @@ -98,79 +92,6 @@ private function getCallable(): HttpMiddlewareCallable return $callable; } - public function toUri(array|string $action, ...$params): string - { - if (is_string($action) && str_starts_with($action, '/')) { - $uri = $action; - } else { - [$controllerClass, $controllerMethod] = is_array($action) ? $action : [$action, '__invoke']; - - $routeAttribute = new ClassReflector($controllerClass) - ->getMethod($controllerMethod) - ->getAttribute(Route::class); - - if ($routeAttribute === null) { - throw new ControllerMethodHadNoRouteAttribute($controllerClass, $controllerMethod); - } - - $uri = $routeAttribute->uri; - } - - $uri = str($uri); - $queryParams = []; - - foreach ($params as $key => $value) { - if (! $uri->matches(sprintf('/\{%s(\}|:)/', $key))) { - $queryParams[$key] = $value; - - continue; - } - - if ($value instanceof BackedEnum) { - $value = $value->value; - } elseif ($value instanceof Bindable) { - foreach (new ClassReflector($value)->getPublicProperties() as $property) { - if (! $property->hasAttribute(IsBindingValue::class)) { - continue; - } - - $value = $property->getValue($value); - break; - } - } - - $uri = $uri->replaceRegex( - '#\{' . $key . DiscoveredRoute::ROUTE_PARAM_CUSTOM_REGEX . '\}#', - (string) $value, - ); - } - - $uri = $uri->prepend(rtrim($this->appConfig->baseUri, '/')); - - if ($queryParams !== []) { - return $uri->append('?' . http_build_query($queryParams))->toString(); - } - - return $uri->toString(); - } - - public function isCurrentUri(array|string $action, ...$params): bool - { - $matchedRoute = $this->container->get(MatchedRoute::class); - $candidateUri = $this->toUri($action, ...[...$matchedRoute->params, ...$params]); - $currentUri = $this->toUri([$matchedRoute->route->handler->getDeclaringClass(), $matchedRoute->route->handler->getName()]); - - foreach ($matchedRoute->params as $key => $value) { - if ($value instanceof BackedEnum) { - $value = $value->value; - } - - $currentUri = replace($currentUri, '/({' . preg_quote($key, '/') . '(?::.*?)?})/', $value); - } - - return $currentUri === $candidateUri; - } - private function createResponse(string|array|Response|View $input): Response { if ($input instanceof View || is_array($input) || is_string($input)) { diff --git a/packages/router/src/Router.php b/packages/router/src/Router.php index 56dba0767..fa8abc2cc 100644 --- a/packages/router/src/Router.php +++ b/packages/router/src/Router.php @@ -5,34 +5,12 @@ namespace Tempest\Router; use Psr\Http\Message\ServerRequestInterface as PsrRequest; +use Tempest\DateTime\DateTime; +use Tempest\DateTime\Duration; use Tempest\Http\Request; use Tempest\Http\Response; interface Router { public function dispatch(Request|PsrRequest $request): Response; - - /** - * Creates a valid URI to the given `$action`. - * - * `$action` is one of : - * - Controller FQCN and its method as a tuple - * - Invokable controller FQCN - * - URI string starting with `/` - * - * @param array{class-string, string}|class-string|string $action - */ - public function toUri(array|string $action, ...$params): string; - - /** - * Checks if the URI to the given `$action` would match the current route. - * - * `$action` is one of : - * - Controller FQCN and its method as a tuple - * - Invokable controller FQCN - * - URI string starting with `/` - * - * @param array{class-string, string}|class-string|string $action - */ - public function isCurrentUri(array|string $action, ...$params): bool; } diff --git a/packages/router/src/Static/StaticGenerateCommand.php b/packages/router/src/Static/StaticGenerateCommand.php index 9d333f2a4..08f6aca25 100644 --- a/packages/router/src/Static/StaticGenerateCommand.php +++ b/packages/router/src/Static/StaticGenerateCommand.php @@ -34,9 +34,9 @@ use Tempest\Vite\Exceptions\ManifestWasNotFound; use Throwable; +use function Tempest\Router\uri; use function Tempest\Support\path; use function Tempest\Support\str; -use function Tempest\uri; final class StaticGenerateCommand { diff --git a/packages/router/src/UriGenerator.php b/packages/router/src/UriGenerator.php new file mode 100644 index 000000000..195a78846 --- /dev/null +++ b/packages/router/src/UriGenerator.php @@ -0,0 +1,219 @@ +get('signature'); + $expiresAt = $request->get('expires_at'); + + if ($signature === null) { + return false; + } + + if ($expiresAt !== null && is_numeric($expiresAt) && DateTime::fromTimestamp((int) $expiresAt)->isPast()) { + return false; + } + + return $this->signer->verify( + data: $this->createUri($request->path, ...Arr\remove_keys($request->query, 'signature')), + signature: Signature::from($signature), + ); + } + + /** + * Creates an absolute URI that is signed with a secret key and will expire after the specified duration. + * + * `$action` is one of : + * - Controller FQCN and its method as a tuple + * - Invokable controller FQCN + * - URI string starting with `/` + * + * @param MethodReflector|array{class-string,string}|class-string|string $action + */ + public function createTemporarySignedUri(array|string|MethodReflector $action, DateTime|Duration|int $duration, mixed ...$params): string + { + $uri = $this->normalizeActionToUri($action); + + if (array_key_exists('expires_at', $params)) { + throw new RuntimeException('Cannot create a signed URI with an "expires_at" parameter. It will be added automatically.'); + } + + if (is_int($duration)) { + $duration = Duration::seconds($duration); + } + + if ($duration instanceof Duration) { + $duration = DateTime::now()->plusMilliseconds((int) $duration->getTotalMilliseconds()); + } + + return $this->createSignedUri($uri, ...[ + ...$params, + 'expires_at' => $duration->getTimestamp()->getSeconds(), + ]); + } + + /** + * Creates an absolute URI that is signed with a secret key, ensuring that it cannot be tampered with. + * + * `$action` is one of : + * - Controller FQCN and its method as a tuple + * - Invokable controller FQCN + * - URI string starting with `/` + * + * @param MethodReflector|array{class-string,string}|class-string|string $action + */ + public function createSignedUri(array|string|MethodReflector $action, mixed ...$params): string + { + $uri = $this->normalizeActionToUri($action); + + if (array_key_exists('signature', $params)) { + throw new RuntimeException('Cannot create a signed URI with a "signature" parameter. It will be added automatically.'); + } + + $this->signer = $this->container->get(Signer::class); + + ksort($params); + + return $this->createUri($uri, ...[ + ...$params, + 'signature' => $this->signer->sign($this->createUri($action, ...$params))->value, + ]); + } + + /** + * Creates an absolute URI to the given `$action`. + * + * `$action` is one of : + * - Controller FQCN and its method as a tuple + * - Invokable controller FQCN + * - URI string starting with `/` + * + * @param MethodReflector|array{class-string,string}|class-string|string $action + */ + public function createUri(array|string|MethodReflector $action, mixed ...$params): string + { + $uri = str($this->normalizeActionToUri($action)); + $queryParams = []; + + foreach ($params as $key => $value) { + if (! $uri->matches(sprintf('/\{%s(\}|:)/', $key))) { + $queryParams[$key] = $value; + + continue; + } + + if ($value instanceof BackedEnum) { + $value = $value->value; + } elseif ($value instanceof Bindable) { + foreach (new ClassReflector($value)->getPublicProperties() as $property) { + if (! $property->hasAttribute(IsBindingValue::class)) { + continue; + } + + $value = $property->getValue($value); + + break; + } + } + + $uri = $uri->replaceRegex( + regex: '#\{' . $key . DiscoveredRoute::ROUTE_PARAM_CUSTOM_REGEX . '\}#', + replace: (string) $value, + ); + } + + $uri = $uri->prepend(rtrim($this->appConfig->baseUri, '/')); + + if ($queryParams !== []) { + return $uri->append('?' . http_build_query($queryParams))->toString(); + } + + return $uri->toString(); + } + + /** + * Checks if the URI to the given `$action` would match the current route. + * + * `$action` is one of : + * - Controller FQCN and its method as a tuple + * - Invokable controller FQCN + * - URI string starting with `/` + * + * @param MethodReflector|array{class-string,string}|class-string|string $action + */ + public function isCurrentUri(array|string|MethodReflector $action, mixed ...$params): bool + { + $action = $this->normalizeActionToUri($action); + + $matchedRoute = $this->container->get(MatchedRoute::class); + $candidateUri = $this->createUri($action, ...[...$matchedRoute->params, ...$params]); + $currentUri = $this->createUri([$matchedRoute->route->handler->getDeclaringClass(), $matchedRoute->route->handler->getName()]); + + foreach ($matchedRoute->params as $key => $value) { + if ($value instanceof BackedEnum) { + $value = $value->value; + } + + $currentUri = Regex\replace($currentUri, '/({' . preg_quote($key, '/') . '(?::.*?)?})/', $value); + } + + return $currentUri === $candidateUri; + } + + private function normalizeActionToUri(array|string|MethodReflector $action): string + { + if ($action instanceof MethodReflector) { + $action = [ + $action->getDeclaringClass()->getName(), + $action->getName(), + ]; + } + + if (is_string($action) && str_starts_with($action, '/')) { + return $action; + } + + [$controllerClass, $controllerMethod] = is_array($action) ? $action : [$action, '__invoke']; + + $routeAttribute = new ClassReflector($controllerClass) + ->getMethod($controllerMethod) + ->getAttribute(Route::class); + + if ($routeAttribute === null) { + throw new ControllerMethodHadNoRouteAttribute($controllerClass, $controllerMethod); + } + + return Str\ensure_starts_with($routeAttribute->uri, '/'); + } +} diff --git a/packages/router/src/functions.php b/packages/router/src/functions.php index 704af0612..33848a8aa 100644 --- a/packages/router/src/functions.php +++ b/packages/router/src/functions.php @@ -2,47 +2,71 @@ declare(strict_types=1); -namespace Tempest { - use Tempest\Reflection\MethodReflector; - use Tempest\Router\Router; - - /** - * Creates a valid URI to the given controller `$action`. - */ - function uri(array|string|MethodReflector $action, mixed ...$params): string - { - if ($action instanceof MethodReflector) { - $action = [ - $action->getDeclaringClass()->getName(), - $action->getName(), - ]; - } - - $router = get(Router::class); - - return $router->toUri( - $action, - ...$params, - ); - } - - /** - * Checks whether the given controller action matches the current URI. - */ - function is_current_uri(array|string|MethodReflector $action, mixed ...$params): bool - { - if ($action instanceof MethodReflector) { - $action = [ - $action->getDeclaringClass()->getName(), - $action->getName(), - ]; - } - - $router = get(Router::class); - - return $router->isCurrentUri( - $action, - ...$params, - ); - } +namespace Tempest\Router; + +use Tempest\DateTime\DateTime; +use Tempest\DateTime\Duration; +use Tempest\Reflection\MethodReflector; +use Tempest\Router\UriGenerator; + +use function Tempest\get; + +/** + * Creates a valid URI to the given `$action`. + * + * `$action` is one of : + * - Controller FQCN and its method as a tuple + * - Invokable controller FQCN + * - URI string starting with `/` + * + * @param MethodReflector|array{class-string,string}|class-string|string $action + */ +function uri(array|string|MethodReflector $action, mixed ...$params): string +{ + return get(UriGenerator::class)->createUri($action, ...$params); +} + +/** + * Creates a URI that is signed with a secret key, ensuring that it cannot be tampered with. + * + * `$action` is one of : + * - Controller FQCN and its method as a tuple + * - Invokable controller FQCN + * - URI string starting with `/` + * + * @param MethodReflector|array{class-string,string}|class-string|string $action + */ +function signed_uri(array|string|MethodReflector $action, mixed ...$params): string +{ + return get(UriGenerator::class)->createSignedUri($action, ...$params); +} + +/** + * Creates an absolute URI that is signed with a secret key and will expire after the specified duration. + * + * `$action` is one of : + * - Controller FQCN and its method as a tuple + * - Invokable controller FQCN + * - URI string starting with `/` + * + * @param MethodReflector|array{class-string,string}|class-string|string $action + */ +function temporary_signed_uri(array|string|MethodReflector $action, DateTime|Duration|int $duration, mixed ...$params): string +{ + return get(UriGenerator::class)->createTemporarySignedUri($action, $duration, ...$params); +} + +/** + * Checks if the URI to the given `$action` would match the current route. + * + * `$action` is one of : + * - Controller FQCN and its method as a tuple + * - Invokable controller FQCN + * - URI string starting with `/` + * + * @param MethodReflector|array{class-string,string}|class-string|string $action + */ +function is_current_uri(array|string|MethodReflector $action, mixed ...$params): bool +{ + return get(UriGenerator::class)->isCurrentUri($action, ...$params); } diff --git a/tests/Fixtures/Controllers/ValidationController.php b/tests/Fixtures/Controllers/ValidationController.php index 6ab114f18..6bc03fde0 100644 --- a/tests/Fixtures/Controllers/ValidationController.php +++ b/tests/Fixtures/Controllers/ValidationController.php @@ -14,7 +14,7 @@ use Tests\Tempest\Fixtures\Requests\BookRequest; use Tests\Tempest\Fixtures\Requests\ValidationRequest; -use function Tempest\uri; +use function Tempest\Router\uri; final readonly class ValidationController { diff --git a/tests/Fixtures/Modules/Books/BookController.php b/tests/Fixtures/Modules/Books/BookController.php index 3fd6ac8a9..0dcee318f 100644 --- a/tests/Fixtures/Modules/Books/BookController.php +++ b/tests/Fixtures/Modules/Books/BookController.php @@ -14,7 +14,7 @@ use Tests\Tempest\Fixtures\Modules\Books\Requests\CreateBookRequest; use function Tempest\map; -use function Tempest\uri; +use function Tempest\Router\uri; final readonly class BookController { diff --git a/tests/Fixtures/Modules/Form/form.view.php b/tests/Fixtures/Modules/Form/form.view.php index 6c3dfbf12..682652896 100644 --- a/tests/Fixtures/Modules/Form/form.view.php +++ b/tests/Fixtures/Modules/Form/form.view.php @@ -3,7 +3,7 @@ use Tempest\View\GenericView; use Tests\Tempest\Fixtures\Modules\Form\FormController; -use function Tempest\uri; +use function Tempest\Router\uri; /** @var GenericView $this */ diff --git a/tests/Fixtures/Views/button-usage.view.php b/tests/Fixtures/Views/button-usage.view.php index 3d1366b04..fca82ec9e 100644 --- a/tests/Fixtures/Views/button-usage.view.php +++ b/tests/Fixtures/Views/button-usage.view.php @@ -2,7 +2,7 @@ use Tests\Tempest\Fixtures\Controllers\DocsController; -use function Tempest\uri; +use function Tempest\Router\uri; ?> diff --git a/tests/Fixtures/Views/x-view-component-with-use-import.view.php b/tests/Fixtures/Views/x-view-component-with-use-import.view.php index 5d76a2bb4..607858f42 100644 --- a/tests/Fixtures/Views/x-view-component-with-use-import.view.php +++ b/tests/Fixtures/Views/x-view-component-with-use-import.view.php @@ -2,7 +2,7 @@ use Tests\Tempest\Fixtures\Modules\Home\HomeController; -use function Tempest\uri; +use function Tempest\Router\uri; ?> diff --git a/tests/Fixtures/x-with-header.view.php b/tests/Fixtures/x-with-header.view.php index abe86bb0c..b72f8f4d4 100644 --- a/tests/Fixtures/x-with-header.view.php +++ b/tests/Fixtures/x-with-header.view.php @@ -2,7 +2,7 @@ use Tests\Tempest\Fixtures\Modules\Home\HomeController; -use function Tempest\uri; +use function Tempest\Router\uri; ?> diff --git a/tests/Integration/Auth/AuthorizerTest.php b/tests/Integration/Auth/AuthorizerTest.php index 973f11c9b..1fef06623 100644 --- a/tests/Integration/Auth/AuthorizerTest.php +++ b/tests/Integration/Auth/AuthorizerTest.php @@ -22,7 +22,7 @@ use Tests\Tempest\Integration\Auth\Fixtures\UserPermissionUnitEnum; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; -use function Tempest\uri; +use function Tempest\Router\uri; /** * @internal diff --git a/tests/Integration/Core/DeferredTasksTest.php b/tests/Integration/Core/DeferredTasksTest.php index 496c406a5..129040906 100644 --- a/tests/Integration/Core/DeferredTasksTest.php +++ b/tests/Integration/Core/DeferredTasksTest.php @@ -11,7 +11,7 @@ use Tests\Tempest\Integration\FrameworkIntegrationTestCase; use function Tempest\defer; -use function Tempest\uri; +use function Tempest\Router\uri; /** * @internal diff --git a/tests/Integration/Http/Static/Fixtures/StaticPageController.php b/tests/Integration/Http/Static/Fixtures/StaticPageController.php index 085fd2a6a..65fcb4baf 100644 --- a/tests/Integration/Http/Static/Fixtures/StaticPageController.php +++ b/tests/Integration/Http/Static/Fixtures/StaticPageController.php @@ -13,7 +13,7 @@ use Tempest\View\View; use Tempest\Vite\Exceptions\ManifestWasNotFound; -use function Tempest\uri; +use function Tempest\Router\uri; use function Tempest\view; final readonly class StaticPageController diff --git a/tests/Integration/Http/ValidationResponseTest.php b/tests/Integration/Http/ValidationResponseTest.php index c2ea2b4f7..07241cdbc 100644 --- a/tests/Integration/Http/ValidationResponseTest.php +++ b/tests/Integration/Http/ValidationResponseTest.php @@ -15,7 +15,7 @@ use Tests\Tempest\Fixtures\Modules\Books\Models\Book; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; -use function Tempest\uri; +use function Tempest\Router\uri; /** * @internal diff --git a/tests/Integration/Http/ViewDataTest.php b/tests/Integration/Http/ViewDataTest.php index a78f63124..d244fac46 100644 --- a/tests/Integration/Http/ViewDataTest.php +++ b/tests/Integration/Http/ViewDataTest.php @@ -10,7 +10,7 @@ use Tests\Tempest\Fixtures\Views\ViewModel; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; -use function Tempest\uri; +use function Tempest\Router\uri; /** * @internal diff --git a/tests/Integration/Route/RequestTest.php b/tests/Integration/Route/RequestTest.php index fe518d93f..67e72f376 100644 --- a/tests/Integration/Route/RequestTest.php +++ b/tests/Integration/Route/RequestTest.php @@ -20,7 +20,7 @@ use Tests\Tempest\Integration\FrameworkIntegrationTestCase; use Tests\Tempest\Integration\Route\Fixtures\MemoryInputStream; -use function Tempest\uri; +use function Tempest\Router\uri; /** * @internal diff --git a/tests/Integration/Route/RouterTest.php b/tests/Integration/Route/RouterTest.php index 110e729f0..f3ec45cd0 100644 --- a/tests/Integration/Route/RouterTest.php +++ b/tests/Integration/Route/RouterTest.php @@ -8,8 +8,6 @@ use Laminas\Diactoros\ServerRequest; use Laminas\Diactoros\Stream; use Laminas\Diactoros\Uri; -use ReflectionException; -use Tempest\Core\AppConfig; use Tempest\Database\Migrations\CreateMigrationsTable; use Tempest\Database\PrimaryKey; use Tempest\Http\HttpRequestFailed; @@ -18,12 +16,8 @@ use Tempest\Router\GenericRouter; use Tempest\Router\RouteConfig; use Tempest\Router\Router; -use Tests\Tempest\Fixtures\Controllers\ControllerWithEnumBinding; -use Tests\Tempest\Fixtures\Controllers\EnumForController; -use Tests\Tempest\Fixtures\Controllers\TestController; use Tests\Tempest\Fixtures\Controllers\TestGlobalMiddleware; use Tests\Tempest\Fixtures\Controllers\TestMiddleware; -use Tests\Tempest\Fixtures\Controllers\UriGeneratorController; use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; use Tests\Tempest\Fixtures\Migrations\CreateBookTable; use Tests\Tempest\Fixtures\Migrations\CreatePublishersTable; @@ -34,8 +28,6 @@ use Tests\Tempest\Integration\Route\Fixtures\HeadController; use Tests\Tempest\Integration\Route\Fixtures\Http500Controller; -use function Tempest\uri; - /** * @internal */ @@ -81,45 +73,6 @@ public function test_dispatch_with_parameter_with_complex_custom_regex(): void $this->assertEquals('1', $response->body); } - public function test_generate_uri(): void - { - $router = $this->container->get(GenericRouter::class); - - $this->assertEquals('/test/1/a', $router->toUri([TestController::class, 'withParams'], id: 1, name: 'a')); - - $this->assertEquals('/test/1', $router->toUri([TestController::class, 'withComplexCustomRegexParams'], id: 1)); - - $this->assertEquals('/test/1/a/b', $router->toUri([TestController::class, 'withCustomRegexParams'], id: 1, name: 'a/b')); - $this->assertEquals('/test', $router->toUri(TestController::class)); - - $this->assertEquals('/test/1/a?q=hi&i=test', $router->toUri([TestController::class, 'withParams'], id: 1, name: 'a', q: 'hi', i: 'test')); - - $appConfig = new AppConfig(baseUri: 'https://test.com'); - $this->container->config($appConfig); - $router = $this->container->get(GenericRouter::class); - $this->assertEquals('https://test.com/test/1/a', $router->toUri([TestController::class, 'withParams'], id: 1, name: 'a')); - - $this->assertSame('https://test.com/abc', $router->toUri('/abc')); - $this->assertEquals('https://test.com/test/1/a/b/c/d', $router->toUri([TestController::class, 'withCustomRegexParams'], id: 1, name: 'a/b/c/d')); - } - - public function test_uri_generation_with_invalid_fqcn(): void - { - $router = $this->container->get(GenericRouter::class); - - $this->expectException(ReflectionException::class); - $router->toUri(TestController::class . 'Invalid'); - } - - public function test_uri_generation_with_query_param(): void - { - $router = $this->container->get(GenericRouter::class); - - $uri = $router->toUri(TestController::class, test: 'foo'); - - $this->assertSame('/test?test=foo', $uri); - } - public function test_with_view(): void { $router = $this->container->get(GenericRouter::class); @@ -203,51 +156,6 @@ public function test_enum_route_binding(): void $this->http->get('/with-enum/unknown')->assertNotFound(); } - public function test_generate_uri_with_enum(): void - { - $this->assertSame( - '/with-enum/foo', - uri(ControllerWithEnumBinding::class, input: EnumForController::FOO), - ); - - $this->assertSame( - '/with-enum/bar', - uri(ControllerWithEnumBinding::class, input: EnumForController::BAR), - ); - } - - public function test_generate_uri_with_bindable_model(): void - { - $book = Book::new( - id: new PrimaryKey('abc'), - ); - - $this->assertSame( - '/books/abc', - uri([BookController::class, 'show'], book: $book), - ); - } - - public function test_generate_uri_with_primary_key(): void - { - $book = Book::new( - id: new PrimaryKey('abc'), - ); - - $this->assertSame( - '/books/abc', - uri([BookController::class, 'show'], book: $book->id), - ); - } - - public function test_uri_with_query_param_that_collides_partially_with_route_param(): void - { - $this->assertSame( - '/test-with-collision/hi?id=1', - uri([UriGeneratorController::class, 'withCollidingNames'], id: '1', idea: 'hi'), - ); - } - public function test_json_request(): void { $router = $this->container->get(Router::class); @@ -265,43 +173,6 @@ public function test_json_request(): void $this->assertSame('foo', $response->body); } - public function test_is_current_uri(): void - { - $router = $this->container->get(GenericRouter::class); - - $this->http->get('/test')->assertOk(); - - $this->assertTrue($router->isCurrentUri([TestController::class, '__invoke'])); - $this->assertFalse($router->isCurrentUri([TestController::class, 'withParams'])); - $this->assertFalse($router->isCurrentUri([TestController::class, 'withParams'], id: 1)); - $this->assertFalse($router->isCurrentUri([TestController::class, 'withParams'], id: 1, name: 'a')); - } - - public function test_is_current_uri_with_constrained_parameters(): void - { - $router = $this->container->get(GenericRouter::class); - - $this->http->get('/test/1/a')->assertOk(); - - $this->assertTrue($router->isCurrentUri([TestController::class, 'withCustomRegexParams'])); - $this->assertTrue($router->isCurrentUri([TestController::class, 'withCustomRegexParams'], id: 1)); - $this->assertTrue($router->isCurrentUri([TestController::class, 'withCustomRegexParams'], id: 1, name: 'a')); - $this->assertFalse($router->isCurrentUri([TestController::class, 'withCustomRegexParams'], id: 1, name: 'b')); - $this->assertFalse($router->isCurrentUri([TestController::class, 'withCustomRegexParams'], id: 0, name: 'a')); - $this->assertFalse($router->isCurrentUri([TestController::class, 'withCustomRegexParams'], id: 0, name: 'b')); - } - - public function test_is_current_uri_with_enum(): void - { - $router = $this->container->get(GenericRouter::class); - - $this->http->get('/with-enum/foo')->assertOk(); - - $this->assertTrue($router->isCurrentUri(ControllerWithEnumBinding::class)); - $this->assertTrue($router->isCurrentUri(ControllerWithEnumBinding::class, input: EnumForController::FOO)); - $this->assertFalse($router->isCurrentUri(ControllerWithEnumBinding::class, input: EnumForController::BAR)); - } - public function test_discovers_response_processors(): void { $this->http diff --git a/tests/Integration/Route/UriGeneratorTest.php b/tests/Integration/Route/UriGeneratorTest.php new file mode 100644 index 000000000..125e45d83 --- /dev/null +++ b/tests/Integration/Route/UriGeneratorTest.php @@ -0,0 +1,252 @@ + $this->container->get(UriGenerator::class); + } + + #[Test] + public function generates_uri(): void + { + $this->assertEquals('/test/1/a', $this->generator->createUri([TestController::class, 'withParams'], id: 1, name: 'a')); + + $this->assertEquals('/test/1', $this->generator->createUri([TestController::class, 'withComplexCustomRegexParams'], id: 1)); + + $this->assertEquals('/test/1/a/b', $this->generator->createUri([TestController::class, 'withCustomRegexParams'], id: 1, name: 'a/b')); + $this->assertEquals('/test', $this->generator->createUri(TestController::class)); + + $this->assertEquals('/test/1/a?q=hi&i=test', $this->generator->createUri([TestController::class, 'withParams'], id: 1, name: 'a', q: 'hi', i: 'test')); + + $this->container->config(new AppConfig(baseUri: 'https://test.com')); + + $this->assertEquals('https://test.com/test/1/a', $this->generator->createUri([TestController::class, 'withParams'], id: 1, name: 'a')); + + $this->assertSame('https://test.com/abc', $this->generator->createUri('/abc')); + $this->assertEquals('https://test.com/test/1/a/b/c/d', $this->generator->createUri([TestController::class, 'withCustomRegexParams'], id: 1, name: 'a/b/c/d')); + } + + #[Test] + public function uri_functions(): void + { + $this->assertEquals('/test/1/a', uri([TestController::class, 'withParams'], id: 1, name: 'a')); + } + + #[Test] + public function uri_generation_with_invalid_fqcn(): void + { + $this->expectException(ReflectionException::class); + + $this->generator->createUri(TestController::class . 'Invalid'); + } + + #[Test] + public function uri_generation_with_query_param(): void + { + $this->assertSame('/test?test=foo', $this->generator->createUri(TestController::class, test: 'foo')); + } + + #[Test] + public function generate_uri_with_enum(): void + { + $this->assertSame( + '/with-enum/foo', + $this->generator->createUri(ControllerWithEnumBinding::class, input: EnumForController::FOO), + ); + + $this->assertSame( + '/with-enum/bar', + $this->generator->createUri(ControllerWithEnumBinding::class, input: EnumForController::BAR), + ); + } + + #[Test] + public function generate_uri_with_bindable_model(): void + { + $book = Book::new(id: new PrimaryKey('abc')); + + $this->assertSame( + '/books/abc', + uri([BookController::class, 'show'], book: $book), + ); + } + + #[Test] + public function generate_uri_with_primary_key(): void + { + $book = Book::new(id: new PrimaryKey('abc')); + + $this->assertSame( + '/books/abc', + uri([BookController::class, 'show'], book: $book->id), + ); + } + + #[Test] + public function uri_with_query_param_that_collides_partially_with_route_param(): void + { + $this->assertSame( + '/test-with-collision/hi?id=1', + $this->generator->createUri([UriGeneratorController::class, 'withCollidingNames'], id: '1', idea: 'hi'), + ); + } + + #[Test] + public function is_current_uri(): void + { + $this->http->get('/test')->assertOk(); + + $this->assertTrue($this->generator->isCurrentUri([TestController::class, '__invoke'])); + $this->assertFalse($this->generator->isCurrentUri([TestController::class, 'withParams'])); + $this->assertFalse($this->generator->isCurrentUri([TestController::class, 'withParams'], id: 1)); + $this->assertFalse($this->generator->isCurrentUri([TestController::class, 'withParams'], id: 1, name: 'a')); + } + + #[Test] + public function is_current_uri_with_constrained_parameters(): void + { + $this->http->get('/test/1/a')->assertOk(); + + $this->assertTrue($this->generator->isCurrentUri([TestController::class, 'withCustomRegexParams'])); + $this->assertTrue($this->generator->isCurrentUri([TestController::class, 'withCustomRegexParams'], id: 1)); + $this->assertTrue($this->generator->isCurrentUri([TestController::class, 'withCustomRegexParams'], id: 1, name: 'a')); + $this->assertFalse($this->generator->isCurrentUri([TestController::class, 'withCustomRegexParams'], id: 1, name: 'b')); + $this->assertFalse($this->generator->isCurrentUri([TestController::class, 'withCustomRegexParams'], id: 0, name: 'a')); + $this->assertFalse($this->generator->isCurrentUri([TestController::class, 'withCustomRegexParams'], id: 0, name: 'b')); + } + + #[Test] + public function is_current_uri_with_enum(): void + { + $this->http->get('/with-enum/foo')->assertOk(); + + $this->assertTrue($this->generator->isCurrentUri(ControllerWithEnumBinding::class)); + $this->assertTrue($this->generator->isCurrentUri(ControllerWithEnumBinding::class, input: EnumForController::FOO)); + $this->assertFalse($this->generator->isCurrentUri(ControllerWithEnumBinding::class, input: EnumForController::BAR)); + } + + #[Test] + public function signed_uri(): void + { + $uri = $this->generator->createSignedUri( + action: [TestController::class, 'withParams'], + id: 1, + name: 'a', + foo: 'bar', + ); + + $this->assertTrue($this->generator->hasValidSignature( + new GenericRequest(Method::POST, $uri), + )); + } + + #[Test] + #[TestWith(['/1', '/2'], name: 'tampered path')] + #[TestWith(['foo=', 'foo=uwu'], name: 'tampered query param')] + #[TestWith(['foo=', 'bar=baz&foo='], name: 'additional query param')] + #[TestWith(['signature=', 'signature=invalid'], name: 'tampered signature')] + public function tampered_uri(string $fragment, string $tamper): void + { + $uri = $this->generator->createSignedUri( + action: [TestController::class, 'withParams'], + id: 1, + name: 'a', + foo: 'bar', + ); + + $this->assertFalse($this->generator->hasValidSignature( + new GenericRequest(Method::POST, str_replace($fragment, $tamper, $uri)), + )); + } + + #[Test] + public function temporary_signed_uri(): void + { + $clock = $this->clock(); + + $uri = $this->generator->createTemporarySignedUri( + action: [TestController::class, 'withParams'], + duration: Duration::minutes(20), + id: 1, + name: 'a', + foo: 'bar', + ); + + $request = new GenericRequest(Method::POST, $uri); + + $this->assertTrue($this->generator->hasValidSignature($request)); + $clock->plus(Duration::minutes(15)); + $this->assertTrue($this->generator->hasValidSignature($request)); + $clock->plus(Duration::minutes(5)); + $this->assertFalse($this->generator->hasValidSignature($request)); + } + + #[Test] + public function tampered_duration_in_signed_uri(): void + { + $clock = $this->clock(); + + $uri = $this->generator->createTemporarySignedUri( + action: [TestController::class, 'withParams'], + duration: Duration::minutes(20), + id: 1, + name: 'a', + foo: 'bar', + ); + + $timestamp = $clock->now()->plusMinutes(20)->getTimestamp()->getSeconds(); + $tamperedUri = str_replace((string) $timestamp, (string) ($timestamp + 20), $uri); + + $this->assertFalse($this->generator->hasValidSignature(new GenericRequest(Method::POST, $tamperedUri))); + } + + #[Test] + public function cannot_add_custom_expires_at(): void + { + $this->expectExceptionMessage('Cannot create a signed URI with an "expires_at" parameter. It will be added automatically.'); + + $this->generator->createTemporarySignedUri( + action: [TestController::class, 'withParams'], + duration: Duration::minutes(20), + id: 1, + name: 'a', + foo: 'bar', + expires_at: 'uwu', + ); + } + + #[Test] + public function cannot_add_custom_signature(): void + { + $this->expectExceptionMessage('Cannot create a signed URI with a "signature" parameter. It will be added automatically.'); + + $this->generator->createSignedUri( + action: [TestController::class, 'withParams'], + id: 1, + name: 'a', + foo: 'bar', + signature: 'uwu', + ); + } +} diff --git a/tests/Integration/View/TempestViewRendererTest.php b/tests/Integration/View/TempestViewRendererTest.php index 629bc978c..37692c063 100644 --- a/tests/Integration/View/TempestViewRendererTest.php +++ b/tests/Integration/View/TempestViewRendererTest.php @@ -10,7 +10,7 @@ use Tests\Tempest\Fixtures\Controllers\RelativeViewController; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; -use function Tempest\uri; +use function Tempest\Router\uri; use function Tempest\view; /** @@ -560,7 +560,7 @@ public function test_html_tags(): void
-