diff --git a/composer.json b/composer.json index 9ddd9f394e..7d7494f779 100644 --- a/composer.json +++ b/composer.json @@ -250,7 +250,7 @@ "lint:fix": "vendor/bin/mago lint --fix --format-after-fix", "style": "composer fmt && composer lint:fix", "test": "composer phpunit", - "test:stop": "composer phpunit --stop-on-error --stop-on-failure", + "test:stop": "composer phpunit -- --stop-on-error --stop-on-failure", "lint": "vendor/bin/mago lint --potentially-unsafe --minimum-fail-level=note", "phpstan": "vendor/bin/phpstan analyse src tests --memory-limit=1G", "rector": "vendor/bin/rector process --no-ansi", diff --git a/docs/1-essentials/01-routing.md b/docs/1-essentials/01-routing.md index cc6d724a58..9f939a1855 100644 --- a/docs/1-essentials/01-routing.md +++ b/docs/1-essentials/01-routing.md @@ -449,12 +449,12 @@ final readonly class AircraftController When users submit forms—like updating profile settings, or posting comments—the data needs validation before processing. Tempest automatically validates request objects using type hints and validation attributes, then provides errors back to users when something is wrong. -On validation failure, Tempest either redirects back to the form (for web pages) or returns a 400 response (for stateless requests). Validation errors are available in two places: +On validation failure, Tempest either redirects back to the form (for web pages) or returns a 422 response (for stateless requests). Validation errors are available in two places: - As a JSON encoded string in the `{txt}X-Validation` header -- Within the session stored in `Session::VALIDATION_ERRORS` +- Through the `b{Tempest\Http\Session\FormSession}` class -The JSON-encoded header is available for APIs built with Tempest. The session errors are available for web pages. For web pages, Tempest provides built-in view components to display errors when they occur. +For web pages, Tempest also provides built-in view components to display errors when they occur. ```html @@ -464,7 +464,7 @@ The JSON-encoded header is available for APIs built with Tempest. The session er ``` -`{html}` is a view component that automatically includes the CSRF token and defaults to sending `POST` requests. `{html}` is a view component that renders a label, input field, and validation errors all at once. +`{html}` is a view component that defaults to sending `POST` requests. `{html}` is a view component that renders a label, input field, and validation errors all at once. :::info These built-in view components can be customized. Run `./tempest install view-components` and select the components to pull into the project. [Read more about installing view components here](../1-essentials/02-views.md#built-in-components). @@ -542,20 +542,33 @@ final readonly class ValidateWebhook implements HttpMiddleware { /* … */ } ``` +### Cross-site request forgery protection + +Tempest provides [cross-site request forgery](https://en.wikipedia.org/wiki/Cross-site_request_forgery) protection based on the presence and values of the [`{txt}Sec-Fetch-Site`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Site) and [`{txt}Sec-Fetch-Mode`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Mode) headers through the {b`Tempest\Router\PreventCrossSiteRequestsMiddleware`} middleware, included by default in all requests. + +Unlike traditional CSRF tokens, this approach uses browser-generated headers that cannot be forged by external websites: + +- [`{txt}Sec-Fetch-Site`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Site) indicates whether the request came from the same domain, subdomain, a different site or if it was user-initiated, such as typing the URL directly, +- [`{txt}Sec-Fetch-Mode`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Mode) allows distinguishing between requests originating from a user navigating between HTML pages, and requests to load images and other resources. + +:::info +This middleware requires browsers that support `{txt}Sec-Fetch-*` headers, which is the case for all modern browsers. You may [exclude this middleware](#excluding-route-middleware) and implement traditional CSRF protection using tokens if you need to support older browsers. +::: + ### Excluding route middleware -Some routes do not require specific global middleware to be applied. For instance, API routes do not need CSRF protection. Specific middleware can be skipped by using the `without` argument of the route attribute. +Some routes do not require specific global middleware to be applied. For instance, a publicly accessible health check endpoint could bypass rate limiting that's applied to other routes. Specific middleware can be skipped by using the `without` argument of the route attribute. -```php app/Slack/ReceiveInteractionController.php -use Tempest\Router\Post; +```php app/HealthCheckController.php +use Tempest\Router\Get; use Tempest\Http\Response; -final readonly class ReceiveInteractionController +final readonly class HealthCheckController { - #[Post('/slack/interaction', without: [VerifyCsrfMiddleware::class, SetCookieMiddleware::class])] + #[Get('/health', without: [RateLimitMiddleware::class])] public function __invoke(): Response { - // … + return new Ok(['status' => 'healthy']); } } ``` @@ -639,8 +652,9 @@ Explicitly removes middleware to all associated routes. ```php use Tempest\Router\WithoutMiddleware; use Tempest\Router\Get; +use Tempest\Router\PreventCrossSiteRequestsMiddleware; -#[WithoutMiddleware(VerifyCsrfMiddleware::class, SetCookieMiddleware::class)] +#[WithoutMiddleware(PreventCrossSiteRequestsMiddleware::class)] final readonly class StatelessController { /* … */ } ``` diff --git a/packages/auth/src/Authentication/AuthenticatorInitializer.php b/packages/auth/src/Authentication/AuthenticatorInitializer.php index 7b13dfb9e9..e87555096b 100644 --- a/packages/auth/src/Authentication/AuthenticatorInitializer.php +++ b/packages/auth/src/Authentication/AuthenticatorInitializer.php @@ -4,11 +4,11 @@ namespace Tempest\Auth\Authentication; -use Tempest\Auth\AuthConfig; use Tempest\Container\Container; use Tempest\Container\Initializer; use Tempest\Container\Singleton; use Tempest\Http\Session\Session; +use Tempest\Http\Session\SessionManager; final readonly class AuthenticatorInitializer implements Initializer { @@ -16,7 +16,7 @@ public function initialize(Container $container): Authenticator { return new SessionAuthenticator( - authConfig: $container->get(AuthConfig::class), + sessionManager: $container->get(SessionManager::class), session: $container->get(Session::class), authenticatableResolver: $container->get(AuthenticatableResolver::class), ); diff --git a/packages/auth/src/Authentication/SessionAuthenticator.php b/packages/auth/src/Authentication/SessionAuthenticator.php index 4b6bbfb915..a7357f9794 100644 --- a/packages/auth/src/Authentication/SessionAuthenticator.php +++ b/packages/auth/src/Authentication/SessionAuthenticator.php @@ -4,8 +4,8 @@ namespace Tempest\Auth\Authentication; -use Tempest\Auth\AuthConfig; use Tempest\Http\Session\Session; +use Tempest\Http\Session\SessionManager; final readonly class SessionAuthenticator implements Authenticator { @@ -13,7 +13,7 @@ public const string AUTHENTICATABLE_CLASS = '#authenticatable:class'; public function __construct( - private AuthConfig $authConfig, + private SessionManager $sessionManager, private Session $session, private AuthenticatableResolver $authenticatableResolver, ) {} @@ -34,7 +34,8 @@ public function authenticate(Authenticatable $authenticatable): void public function deauthenticate(): void { $this->session->remove(self::AUTHENTICATABLE_KEY); - $this->session->destroy(); + $this->session->remove(self::AUTHENTICATABLE_CLASS); + $this->sessionManager->save($this->session); } public function current(): ?Authenticatable diff --git a/packages/core/src/AppConfig.php b/packages/core/src/AppConfig.php index bf0092a61d..e2109696d3 100644 --- a/packages/core/src/AppConfig.php +++ b/packages/core/src/AppConfig.php @@ -10,16 +10,13 @@ final class AppConfig { public string $baseUri; + /** @var array> */ + public array $insightsProviders = []; + public function __construct( public ?string $name = null, - ?string $baseUri = null, - - /** - * @var array> - */ - public array $insightsProviders = [], ) { - $this->baseUri = $baseUri ?? env('BASE_URI') ?? ''; + $this->baseUri = $baseUri ?: env('BASE_URI', default: ''); } } diff --git a/packages/core/src/FrameworkKernel.php b/packages/core/src/FrameworkKernel.php index 989340a389..9fb47ddfeb 100644 --- a/packages/core/src/FrameworkKernel.php +++ b/packages/core/src/FrameworkKernel.php @@ -245,6 +245,7 @@ public function registerExceptionHandler(): self $handler = $this->container->get(ExceptionHandler::class); + ini_set('display_errors', 'Off'); // @mago-expect lint:no-ini-set set_exception_handler($handler->handle(...)); set_error_handler(function (int $code, string $message, string $filename, int $line) use ($handler): bool { $handler->handle(new ErrorException( @@ -255,7 +256,7 @@ public function registerExceptionHandler(): self )); return true; - }, error_levels: E_ERROR); + }, error_levels: E_ALL); return $this; } diff --git a/packages/http/src/Cookie/Cookie.php b/packages/http/src/Cookie/Cookie.php index 7a704d6cc0..71ff00fd6c 100644 --- a/packages/http/src/Cookie/Cookie.php +++ b/packages/http/src/Cookie/Cookie.php @@ -28,9 +28,9 @@ public function __construct( public ?int $maxAge = null, public ?string $domain = null, public ?string $path = '/', - public bool $secure = false, + public bool $secure = true, public bool $httpOnly = false, - public ?SameSite $sameSite = null, + public SameSite $sameSite = SameSite::LAX, ) {} public function withValue(string $value): self diff --git a/packages/http/src/Header.php b/packages/http/src/Header.php index e0ea0dc1f3..1b28132aea 100644 --- a/packages/http/src/Header.php +++ b/packages/http/src/Header.php @@ -4,6 +4,8 @@ namespace Tempest\Http; +use BackedEnum; + final class Header { public function __construct( @@ -14,6 +16,10 @@ public function __construct( public function add(mixed $value): void { + if ($value instanceof BackedEnum) { + $value = $value->value; + } + $this->values[] = $value; } diff --git a/packages/http/src/IsResponse.php b/packages/http/src/IsResponse.php index 8341c80591..105206e2c3 100644 --- a/packages/http/src/IsResponse.php +++ b/packages/http/src/IsResponse.php @@ -10,6 +10,7 @@ use Tempest\Http\Cookie\CookieManager; use Tempest\Http\Session\Session; use Tempest\View\View; +use UnitEnum; use function Tempest\get; @@ -23,14 +24,14 @@ trait IsResponse /** @var \Tempest\Http\Header[] */ private(set) array $headers = []; - public Session $session { - get => get(Session::class); - } - public CookieManager $cookieManager { get => get(CookieManager::class); } + public Session $session { + get => get(Session::class); + } + private(set) ?View $view = null; public function getHeader(string $name): ?Header @@ -72,42 +73,35 @@ public function removeHeader(string $key): self return $this; } - public function addSession(string $name, mixed $value): self - { - $this->session->set($name, $value); - - return $this; - } - - public function removeSession(string $name): self + public function addCookie(Cookie $cookie): self { - $this->session->remove($name); + $this->cookieManager->add($cookie); return $this; } - public function destroySession(): self + public function removeCookie(string $key): self { - $this->session->destroy(); + $this->cookieManager->remove($key); return $this; } - public function addCookie(Cookie $cookie): self + public function addSession(string $name, mixed $value): self { - $this->cookieManager->add($cookie); + $this->session->set($name, $value); return $this; } - public function removeCookie(string $key): self + public function removeSession(string $name): self { - $this->cookieManager->remove($key); + $this->session->remove($name); return $this; } - public function flash(string $key, mixed $value): self + public function flash(string|UnitEnum $key, mixed $value): self { $this->session->flash($key, $value); diff --git a/packages/http/src/RequestHeaders.php b/packages/http/src/RequestHeaders.php index 9bd1b88246..9221155fb4 100644 --- a/packages/http/src/RequestHeaders.php +++ b/packages/http/src/RequestHeaders.php @@ -8,6 +8,7 @@ use ArrayIterator; use IteratorAggregate; use LogicException; +use Tempest\Support\Str; use Traversable; final readonly class RequestHeaders implements ArrayAccess, IteratorAggregate @@ -17,11 +18,10 @@ */ public static function normalizeFromArray(array $headers): self { - $normalized = array_combine( + return new self(array_combine( array_map(strtolower(...), array_keys($headers)), - array_values($headers), - ); - return new self($normalized); + array_values(array_map(fn (mixed $value) => Str\parse($value), $headers)), + )); } /** @param array $headers */ diff --git a/packages/http/src/Response.php b/packages/http/src/Response.php index 36954b5fda..ea7bac8b36 100644 --- a/packages/http/src/Response.php +++ b/packages/http/src/Response.php @@ -8,41 +8,80 @@ use JsonSerializable; use Tempest\Http\Cookie\Cookie; use Tempest\View\View; +use UnitEnum; interface Response { + /** + * Gets the status code of the response. + */ public Status $status { get; } - /** @var \Tempest\Http\Header[] $headers */ + /** + * Gets the headers of the response. + * + * @var \Tempest\Http\Header[] $headers + */ public array $headers { get; } + /** + * Gets the body of the response. + */ public View|string|array|Generator|JsonSerializable|null $body { get; } + /** + * Gets a header by its name, case insensitive. + */ public function getHeader(string $name): ?Header; + /** + * Adds a header to the response. + */ public function addHeader(string $key, string $value): self; + /** + * Removes a header from the response. + */ public function removeHeader(string $key): self; + /** + * Adds a value to the session. + */ public function addSession(string $name, mixed $value): self; - public function flash(string $key, mixed $value): self; - + /** + * Removes a value from the session. + */ public function removeSession(string $name): self; - public function destroySession(): self; + /** + * Flash a value to the session for the next request. + */ + public function flash(string|UnitEnum $key, mixed $value): self; + /** + * Adds a cookie to the response. + */ public function addCookie(Cookie $cookie): self; + /** + * Removes a cookie from the response. + */ public function removeCookie(string $key): self; + /** + * Sets the status code of the response. + */ public function setStatus(Status $status): self; + /** + * Sets the body of the response. + */ public function setBody(View|string|array|Generator|null $body): self; } diff --git a/packages/http/src/Responses/Back.php b/packages/http/src/Responses/Back.php index e36b932566..e26ef8bb01 100644 --- a/packages/http/src/Responses/Back.php +++ b/packages/http/src/Responses/Back.php @@ -7,7 +7,7 @@ use Tempest\Http\IsResponse; use Tempest\Http\Request; use Tempest\Http\Response; -use Tempest\Http\Session\Session; +use Tempest\Http\Session\PreviousUrl; use Tempest\Http\Status; use function Tempest\get; @@ -22,20 +22,14 @@ final class Back implements Response public function __construct(?string $fallback = null) { $this->status = Status::FOUND; - $request = get(Request::class); - - $url = $request->headers['referer'] ?? $request->getSessionValue(Session::PREVIOUS_URL); - if ($url) { - $this->addHeader('Location', $url); - return; - } + $previousUrl = get(PreviousUrl::class); + $request = get(Request::class); - if ($fallback) { - $this->addHeader('Location', $fallback); - return; - } + $url = $previousUrl->get( + default: $request->headers['referer'] ?? $fallback ?? '/', + ); - $this->addHeader('Location', '/'); + $this->addHeader('Location', value: $url); } } diff --git a/packages/http/src/Session/CleanupSessionsCommand.php b/packages/http/src/Session/CleanupSessionsCommand.php index 829b8d55fb..a7335ceda3 100644 --- a/packages/http/src/Session/CleanupSessionsCommand.php +++ b/packages/http/src/Session/CleanupSessionsCommand.php @@ -22,10 +22,12 @@ public function __construct( #[Schedule(Every::MINUTE)] public function __invoke(): void { - $this->eventBus->listen(function (SessionDestroyed $event): void { - $this->console->keyValue((string) $event->id, ""); - }); + $this->eventBus->listen($this->onSessionDeleted(...)); + $this->sessionManager->deleteExpiredSessions(); + } - $this->sessionManager->cleanup(); + private function onSessionDeleted(SessionDeleted $event): void + { + $this->console->keyValue((string) $event->id, ""); } } diff --git a/packages/http/src/Session/CsrfTokenDidNotMatch.php b/packages/http/src/Session/CsrfTokenDidNotMatch.php deleted file mode 100644 index c71bedda02..0000000000 --- a/packages/http/src/Session/CsrfTokenDidNotMatch.php +++ /dev/null @@ -1,13 +0,0 @@ - $errors + */ + public function setErrors(array $errors): void + { + $this->session->flash(self::VALIDATION_ERRORS_KEY, $errors); + } + + /** + * Gets all validation errors. + * + * @return array + */ + public function getErrors(): array + { + return $this->session->get(self::VALIDATION_ERRORS_KEY, []); + } + + /** + * Gets validation errors for a specific field. + * + * @return FailingRule[] + */ + public function getErrorsFor(string $field): array + { + return $this->getErrors()[$field] ?? []; + } + + /** + * Checks if there are any validation errors. + */ + public function hasErrors(): bool + { + return $this->getErrors() !== []; + } + + /** + * Checks if a specific field has validation errors. + */ + public function hasError(string $field): bool + { + return $this->getErrorsFor($field) !== []; + } + + /** + * Stores each field's original form values for the next request. + * + * @param array $values + */ + public function setOriginalValues(array $values): void + { + $this->session->flash(self::ORIGINAL_VALUES_KEY, $values); + } + + /** + * Gets all original form values. The keys are the form fields. + * + * @return array + */ + public function values(): array + { + return $this->session->get(self::ORIGINAL_VALUES_KEY, []); + } + + /** + * Gets the original value for a specific field. + */ + public function getOriginalValueFor(string $field, mixed $default = null): mixed + { + return $this->values()[$field] ?? $default; + } + + /** + * Clears all validation errors and original values. + */ + public function clear(): void + { + $this->session->remove(self::VALIDATION_ERRORS_KEY); + $this->session->remove(self::ORIGINAL_VALUES_KEY); + } +} diff --git a/packages/http/src/Session/Installer/CreateSessionsTable.php b/packages/http/src/Session/Installer/CreateSessionsTable.php index 27171765b2..ad32c6f8da 100644 --- a/packages/http/src/Session/Installer/CreateSessionsTable.php +++ b/packages/http/src/Session/Installer/CreateSessionsTable.php @@ -17,11 +17,9 @@ final class CreateSessionsTable implements MigratesUp public function up(): QueryStatement { return new CreateTableStatement('sessions') - ->primary('id') - ->string('session_id') - ->text('data') + ->uuid('id') ->datetime('created_at') ->datetime('last_active_at') - ->index('session_id'); + ->text('data'); } } diff --git a/packages/http/src/Session/ManageSessionMiddleware.php b/packages/http/src/Session/ManageSessionMiddleware.php new file mode 100644 index 0000000000..d15e654e7c --- /dev/null +++ b/packages/http/src/Session/ManageSessionMiddleware.php @@ -0,0 +1,32 @@ +session->cleanup(); + $this->sessionManager->save($this->session); + $this->sessionManager->deleteExpiredSessions(); + } + } +} diff --git a/packages/http/src/Session/Managers/DatabaseSession.php b/packages/http/src/Session/Managers/DatabaseSession.php index 4652c5c96e..fb53f394de 100644 --- a/packages/http/src/Session/Managers/DatabaseSession.php +++ b/packages/http/src/Session/Managers/DatabaseSession.php @@ -6,15 +6,15 @@ use Tempest\Database\PrimaryKey; use Tempest\Database\Table; +use Tempest\Database\Uuid; use Tempest\DateTime\DateTime; #[Table('sessions')] final class DatabaseSession { + #[Uuid] public PrimaryKey $id; - public string $session_id; - public string $data; public DateTime $created_at; diff --git a/packages/http/src/Session/Managers/DatabaseSessionManager.php b/packages/http/src/Session/Managers/DatabaseSessionManager.php index 5cefb00bfb..273c795146 100644 --- a/packages/http/src/Session/Managers/DatabaseSessionManager.php +++ b/packages/http/src/Session/Managers/DatabaseSessionManager.php @@ -5,14 +5,13 @@ namespace Tempest\Http\Session\Managers; use Tempest\Clock\Clock; -use Tempest\Database\Database; +use Tempest\DateTime\FormatPattern; use Tempest\Http\Session\Session; use Tempest\Http\Session\SessionConfig; -use Tempest\Http\Session\SessionDestroyed; +use Tempest\Http\Session\SessionCreated; +use Tempest\Http\Session\SessionDeleted; use Tempest\Http\Session\SessionId; use Tempest\Http\Session\SessionManager; -use Tempest\Support\Arr; -use Tempest\Support\Arr\ArrayInterface; use function Tempest\Database\query; use function Tempest\event; @@ -22,83 +21,100 @@ public function __construct( private Clock $clock, private SessionConfig $config, - private Database $database, ) {} - public function create(SessionId $id): Session + public function getOrCreate(SessionId $id): Session { - return $this->persist($id); - } - - public function set(SessionId $id, string $key, mixed $value): void - { - $this->persist($id, [...$this->getData($id), ...[$key => $value]]); - } + $now = $this->clock->now(); + $session = $this->load($id); - public function get(SessionId $id, string $key, mixed $default = null): mixed - { - $value = Arr\get_by_key($this->getData($id), $key, $default); + if ($session === null) { + $session = new Session( + id: $id, + createdAt: $now, + lastActiveAt: $now, + ); - if ($value instanceof ArrayInterface) { - return $value->toArray(); + event(new SessionCreated($session)); } - return $value; + return $session; } - public function all(SessionId $id): array + public function save(Session $session): void { - return $this->getData($id); - } + $session->lastActiveAt = $this->clock->now(); - public function remove(SessionId $id, string $key): void - { - $data = $this->getData($id); - $data = Arr\remove_keys($data, $key); + $existing = query(DatabaseSession::class) + ->select() + ->where('id', (string) $session->id) + ->first(); + + if ($existing === null) { + query(DatabaseSession::class) + ->insert( + id: (string) $session->id, + data: serialize($session->data), + created_at: $session->createdAt, + last_active_at: $session->lastActiveAt, + ) + ->execute(); + + return; + } - $this->persist($id, $data); + query(DatabaseSession::class) + ->update( + data: serialize($session->data), + last_active_at: $session->lastActiveAt, + ) + ->where('id', (string) $session->id) + ->execute(); } - public function destroy(SessionId $id): void + public function delete(Session $session): void { query(DatabaseSession::class) ->delete() - ->where('session_id', (string) $id) + ->where('id', (string) $session->id) ->execute(); - event(new SessionDestroyed($id)); + event(new SessionDeleted($session->id)); } - public function isValid(SessionId $id): bool + public function isValid(Session $session): bool { - $session = $this->resolve($id); - - if ($session === null) { - return false; - } - return $this->clock->now()->before( other: $session->lastActiveAt->plus($this->config->expiration), ); } - public function cleanup(): void + public function deleteExpiredSessions(): void { $expired = $this->clock ->now() ->minus($this->config->expiration); - query(DatabaseSession::class) - ->delete() - ->whereBefore('last_active_at', $expired) - ->execute(); + $expiredSessions = query(DatabaseSession::class) + ->select() + ->where('last_active_at < ?', $expired->format(FormatPattern::SQL_DATE_TIME)) + ->all(); + + foreach ($expiredSessions as $expiredSession) { + query(DatabaseSession::class) + ->delete() + ->where('id', $expiredSession->id) + ->execute(); + + event(new SessionDeleted(new SessionId((string) $expiredSession->id))); + } } - private function resolve(SessionId $id): ?Session + private function load(SessionId $id): ?Session { $session = query(DatabaseSession::class) ->select() - ->where('session_id', (string) $id) + ->where('id', (string) $id) ->first(); if ($session === null) { @@ -112,39 +128,4 @@ private function resolve(SessionId $id): ?Session data: unserialize($session->data), ); } - - /** - * @return array - */ - private function getData(SessionId $id): array - { - return $this->resolve($id)->data ?? []; - } - - /** - * @param array|null $data - */ - private function persist(SessionId $id, ?array $data = null): Session - { - $now = $this->clock->now(); - $session = $this->resolve($id) ?? new Session( - id: $id, - createdAt: $now, - lastActiveAt: $now, - ); - - if ($data !== null) { - $session->data = $data; - } - - query(DatabaseSession::class)->updateOrCreate([ - 'session_id' => (string) $id, - ], [ - 'data' => serialize($session->data), - 'created_at' => $session->createdAt, - 'last_active_at' => $now, - ]); - - return $session; - } } diff --git a/packages/http/src/Session/Managers/FileSessionManager.php b/packages/http/src/Session/Managers/FileSessionManager.php index 5043085f0e..598d8f6187 100644 --- a/packages/http/src/Session/Managers/FileSessionManager.php +++ b/packages/http/src/Session/Managers/FileSessionManager.php @@ -7,11 +7,11 @@ use Tempest\Clock\Clock; use Tempest\Http\Session\Session; use Tempest\Http\Session\SessionConfig; -use Tempest\Http\Session\SessionDestroyed; +use Tempest\Http\Session\SessionCreated; +use Tempest\Http\Session\SessionDeleted; use Tempest\Http\Session\SessionId; use Tempest\Http\Session\SessionManager; use Tempest\Support\Filesystem; -use Throwable; use function Tempest\event; use function Tempest\internal_storage_path; @@ -23,60 +23,76 @@ public function __construct( private SessionConfig $sessionConfig, ) {} - public function create(SessionId $id): Session + public function getOrCreate(SessionId $id): Session { - return $this->persist($id); - } + $now = $this->clock->now(); + $session = $this->load($id); - public function set(SessionId $id, string $key, mixed $value): void - { - $this->persist($id, [...$this->getData($id), ...[$key => $value]]); + if ($session === null) { + $session = new Session( + id: $id, + createdAt: $now, + lastActiveAt: $now, + ); + + event(new SessionCreated($session)); + } + + return $session; } - public function get(SessionId $id, string $key, mixed $default = null): mixed + public function save(Session $session): void { - return $this->getData($id)[$key] ?? $default; + $session->lastActiveAt = $this->clock->now(); + + Filesystem\write_file( + filename: $this->getPath($session->id), + content: serialize($session), + flags: LOCK_EX, + ); } - public function remove(SessionId $id, string $key): void + public function delete(Session $session): void { - $data = $this->getData($id); + $path = $this->getPath($session->id); - unset($data[$key]); + Filesystem\delete($path); - $this->persist($id, $data); + event(new SessionDeleted($session->id)); } - public function destroy(SessionId $id): void + public function isValid(Session $session): bool { - unlink($this->getPath($id)); - - event(new SessionDestroyed($id)); + return $this->clock->now()->before( + other: $session->lastActiveAt->plus($this->sessionConfig->expiration), + ); } - public function isValid(SessionId $id): bool + public function deleteExpiredSessions(): void { - $session = $this->resolve($id); + $sessionFiles = glob(internal_storage_path($this->sessionConfig->path, '/*')); - if ($session === null) { - return false; + if ($sessionFiles === false) { + return; } - if (! ($session->lastActiveAt ?? null)) { - return false; - } + foreach ($sessionFiles as $sessionFile) { + $id = new SessionId(pathinfo($sessionFile, flags: PATHINFO_FILENAME)); + $session = $this->load($id); - return $this->clock->now()->before( - other: $session->lastActiveAt->plus($this->sessionConfig->expiration), - ); - } + if ($session === null) { + continue; + } - private function getPath(SessionId $id): string - { - return internal_storage_path($this->sessionConfig->path, (string) $id); + if ($this->isValid($session)) { + continue; + } + + $this->delete($session); + } } - private function resolve(SessionId $id): ?Session + private function load(SessionId $id): ?Session { $path = $this->getPath($id); @@ -85,74 +101,19 @@ private function resolve(SessionId $id): ?Session return null; } - $file_pointer = fopen($path, 'rb'); - flock($file_pointer, LOCK_SH); - - $content = Filesystem\read_file($path); - - flock($file_pointer, LOCK_UN); - fclose($file_pointer); - - return unserialize($content, ['allowed_classes' => true]); - } catch (Throwable) { + return unserialize( + data: Filesystem\read_locked_file($path), + options: [ + 'allowed_classes' => true, + ], + ); + } catch (Filesystem\Exceptions\FilesystemException) { return null; } } - public function all(SessionId $id): array - { - return $this->getData($id); - } - - /** - * @return array - */ - private function getData(SessionId $id): array - { - return $this->resolve($id)->data ?? []; - } - - /** - * @param array|null $data - */ - private function persist(SessionId $id, ?array $data = null): Session - { - $now = $this->clock->now(); - $session = $this->resolve($id) ?? new Session( - id: $id, - createdAt: $now, - lastActiveAt: $now, - ); - - $session->lastActiveAt = $now; - - if ($data !== null) { - $session->data = $data; - } - - Filesystem\write_file($this->getPath($id), serialize($session), LOCK_EX); - - return $session; - } - - public function cleanup(): void + private function getPath(SessionId $id): string { - $sessionFiles = glob(internal_storage_path($this->sessionConfig->path, '/*')); - - foreach ($sessionFiles as $sessionFile) { - $id = new SessionId(pathinfo($sessionFile, PATHINFO_FILENAME)); - - $session = $this->resolve($id); - - if ($session === null) { - continue; - } - - if ($this->isValid($session->id)) { - continue; - } - - $session->destroy(); - } + return internal_storage_path($this->sessionConfig->path, (string) $id); } } diff --git a/packages/http/src/Session/Managers/RedisSessionManager.php b/packages/http/src/Session/Managers/RedisSessionManager.php index ffa5c5b9d4..c9344802c4 100644 --- a/packages/http/src/Session/Managers/RedisSessionManager.php +++ b/packages/http/src/Session/Managers/RedisSessionManager.php @@ -5,13 +5,14 @@ namespace Tempest\Http\Session\Managers; use Tempest\Clock\Clock; +use Tempest\Http\Session\Config\RedisSessionConfig; use Tempest\Http\Session\Session; -use Tempest\Http\Session\SessionConfig; -use Tempest\Http\Session\SessionDestroyed; +use Tempest\Http\Session\SessionCreated; +use Tempest\Http\Session\SessionDeleted; use Tempest\Http\Session\SessionId; use Tempest\Http\Session\SessionManager; use Tempest\KeyValue\Redis\Redis; -use Tempest\Support\Str\ImmutableString; +use Tempest\Support\Str; use Throwable; use function Tempest\event; @@ -21,133 +22,96 @@ public function __construct( private Clock $clock, private Redis $redis, - private SessionConfig $sessionConfig, + private RedisSessionConfig $config, ) {} - public function create(SessionId $id): Session + public function getOrCreate(SessionId $id): Session { - return $this->persist($id); - } + $now = $this->clock->now(); + $session = $this->load($id); - public function set(SessionId $id, string $key, mixed $value): void - { - $this->persist($id, [...$this->getData($id), ...[$key => $value]]); - } + if ($session === null) { + $session = new Session( + id: $id, + createdAt: $now, + lastActiveAt: $now, + ); - public function get(SessionId $id, string $key, mixed $default = null): mixed - { - return $this->getData($id)[$key] ?? $default; + event(new SessionCreated($session)); + } + + return $session; } - public function remove(SessionId $id, string $key): void + public function save(Session $session): void { - $data = $this->getData($id); - - unset($data[$key]); + $session->lastActiveAt = $this->clock->now(); - $this->persist($id, $data); + $this->redis->set( + key: $this->getKey($session->id), + value: serialize($session), + expiration: $this->config->expiration, + ); } - public function destroy(SessionId $id): void + public function delete(Session $session): void { - $this->redis->command('UNLINK', $this->getKey($id)); + $this->redis->command('UNLINK', $this->getKey($session->id)); - event(new SessionDestroyed($id)); + event(new SessionDeleted($session->id)); } - public function isValid(SessionId $id): bool + public function isValid(Session $session): bool { - $session = $this->resolve($id); - - if ($session === null) { - return false; - } - - if (! ($session->lastActiveAt ?? null)) { - return false; - } - return $this->clock->now()->before( - other: $session->lastActiveAt->plus($this->sessionConfig->expiration), + other: $session->lastActiveAt->plus($this->config->expiration), ); } - private function resolve(SessionId $id): ?Session - { - try { - $content = $this->redis->get($this->getKey($id)); - return unserialize($content, ['allowed_classes' => true]); - } catch (Throwable) { - return null; - } - } - - public function all(SessionId $id): array + public function deleteExpiredSessions(): void { - return $this->getData($id); - } - - /** - * @return array - */ - private function getData(SessionId $id): array - { - return $this->resolve($id)->data ?? []; - } + $cursor = '0'; - /** - * @param array|null $data - */ - private function persist(SessionId $id, ?array $data = null): Session - { - $now = $this->clock->now(); - $session = $this->resolve($id) ?? new Session( - id: $id, - createdAt: $now, - lastActiveAt: $now, - ); + do { + /** @var array $keys */ + [$cursor, $keys] = $this->redis->command('SCAN', $cursor, 'MATCH', "{$this->config->prefix}*", 'COUNT', '100'); - $session->lastActiveAt = $now; + foreach ($keys as $key) { + $sessionId = $this->getSessionIdFromKey($key); + $session = $this->load($sessionId); - if ($data !== null) { - $session->data = $data; - } + if ($session === null) { + continue; + } - $this->redis->set($this->getKey($id), serialize($session), $this->sessionConfig->expiration); + if ($this->isValid($session)) { + continue; + } - return $session; + $this->delete($session); + } + } while ($cursor !== '0'); } - private function getKey(SessionId $id): string + private function load(SessionId $id): ?Session { - return sprintf('%s%s', $this->sessionConfig->prefix, $id); + try { + return unserialize( + data: $this->redis->get($this->getKey($id)), + options: ['allowed_classes' => true], + ); + } catch (Throwable) { + return null; + } } - private function getSessionIdFromKey(string $key): SessionId + private function getKey(SessionId $id): string { - return new SessionId( - new ImmutableString($key) - ->afterFirst($this->sessionConfig->prefix) - ->toString(), - ); + return sprintf('%s%s', $this->config->prefix, $id); } - public function cleanup(): void + private function getSessionIdFromKey(string $key): SessionId { - $cursor = '0'; - - do { - $result = $this->redis->command('SCAN', $cursor, 'MATCH', $this->getKey(new SessionId('*')), 'COUNT', '100'); - $cursor = $result[0]; - foreach ($result[1] as $key) { - $sessionId = $this->getSessionIdFromKey($key); - - if ($this->isValid($sessionId)) { - continue; - } - - $this->destroy($sessionId); - } - } while ($cursor !== '0'); + return new SessionId(Str\after_first($key, $this->config->prefix)); } } diff --git a/packages/http/src/Session/PreviousUrl.php b/packages/http/src/Session/PreviousUrl.php new file mode 100644 index 0000000000..f5b78c094d --- /dev/null +++ b/packages/http/src/Session/PreviousUrl.php @@ -0,0 +1,74 @@ +shouldNotTrack($request)) { + return; + } + + $this->session->set(self::PREVIOUS_URL_SESSION_KEY, $request->uri); + } + + /** + * Gets the previous URL, or a default fallback. + */ + public function get(string $default = '/'): string + { + return $this->session->get(self::PREVIOUS_URL_SESSION_KEY, $default); + } + + /** + * Stores the URL where user was trying to go before being redirected. After authentication, the user should be redirect to that URL. + */ + public function setIntended(string $url): void + { + $this->session->set(self::INTENDED_URL_SESSION_KEY, $url); + } + + /** + * Gets and consume the intended URL. + */ + public function getIntended(string $default = '/'): string + { + return $this->session->consume(self::INTENDED_URL_SESSION_KEY, $default); + } + + private function shouldNotTrack(Request $request): bool + { + if ($request->headers->get('x-requested-with') === 'XMLHttpRequest') { + return true; + } + + if ($request->method !== Method::GET) { + return true; + } + + if ($request->headers->get('purpose') === 'prefetch') { + return true; + } + + return false; + } +} diff --git a/packages/http/src/Session/Resolvers/CookieSessionIdResolver.php b/packages/http/src/Session/Resolvers/CookieSessionIdResolver.php index 0d86263efa..4c5077ac76 100644 --- a/packages/http/src/Session/Resolvers/CookieSessionIdResolver.php +++ b/packages/http/src/Session/Resolvers/CookieSessionIdResolver.php @@ -14,6 +14,7 @@ use Tempest\Http\Session\SessionConfig; use Tempest\Http\Session\SessionId; use Tempest\Http\Session\SessionIdResolver; +use Tempest\Support\Str; use function Tempest\Support\str; @@ -44,7 +45,7 @@ public function resolve(): SessionId value: $id, expiresAt: $this->clock->now()->plus($this->sessionConfig->expiration), path: '/', - secure: str($this->appConfig->baseUri)->startsWith('https'), + secure: Str\starts_with($this->appConfig->baseUri, needles: 'https'), httpOnly: true, sameSite: SameSite::LAX, )); diff --git a/packages/http/src/Session/Session.php b/packages/http/src/Session/Session.php index fb275508df..bcd5d2d160 100644 --- a/packages/http/src/Session/Session.php +++ b/packages/http/src/Session/Session.php @@ -4,59 +4,46 @@ namespace Tempest\Http\Session; +use Tempest\DateTime\DateTime; use Tempest\DateTime\DateTimeInterface; -use Tempest\Support\Random; - -use function Tempest\get; - +use Tempest\Support\Str; +use UnitEnum; + +/** + * Represents the current session. + * + * @see ManageSessionMiddleware + * @see SessionManager + */ final class Session { - public const string VALIDATION_ERRORS = '#validation_errors'; - - public const string ORIGINAL_VALUES = '#original_values'; - - public const string PREVIOUS_URL = '#previous_url'; - - public const string CSRF_TOKEN_KEY = '#csrf_token'; - - private array $expiredKeys = []; - - private SessionManager $manager { - get => get(SessionManager::class); - } - /** - * Session token used for cross-site request forgery protection. + * Stores the keys for session values that have expired. */ - public string $token { - get { - if (! $this->get(self::CSRF_TOKEN_KEY)) { - $this->set(self::CSRF_TOKEN_KEY, Random\uuid()); - } - - return $this->get(self::CSRF_TOKEN_KEY); - } - } + private array $expiredKeys = []; public function __construct( - public SessionId $id, - public DateTimeInterface $createdAt, + private(set) SessionId $id, + private(set) DateTimeInterface $createdAt, public DateTimeInterface $lastActiveAt, - /** @var array */ - public array $data = [], + /** @var array */ + private(set) array $data = [], ) {} - public function set(string $key, mixed $value): void + /** + * Sets a value in the session. + */ + public function set(string|UnitEnum $key, mixed $value): void { - $this->manager->set($this->id, $key, $value); + $this->data[Str\parse($key)] = $value; } /** * Stores a value in the session that will be available for the next request only. */ - public function flash(string $key, mixed $value): void + public function flash(string|UnitEnum $key, mixed $value): void { - $this->manager->set($this->id, $key, new FlashValue($value)); + $this->data[Str\parse($key)] = new FlashValue($value); } /** @@ -64,7 +51,7 @@ public function flash(string $key, mixed $value): void */ public function reflash(): void { - foreach ($this->manager->all($this->id) as $key => $value) { + foreach ($this->data as $key => $value) { if (! $value instanceof FlashValue) { continue; } @@ -73,9 +60,13 @@ public function reflash(): void } } - public function get(string $key, mixed $default = null): mixed + /** + * Retrieves a value from the session. + */ + public function get(string|UnitEnum $key, mixed $default = null): mixed { - $value = $this->manager->get($this->id, $key, $default); + $key = Str\parse($key); + $value = $this->data[$key] ?? $default; if ($value instanceof FlashValue) { $this->expiredKeys[$key] = $key; @@ -85,32 +76,12 @@ public function get(string $key, mixed $default = null): mixed return $value; } - /** @return \Tempest\Validation\Rule[] */ - public function getErrorsFor(string $name): array - { - return $this->get(self::VALIDATION_ERRORS)[$name] ?? []; - } - - public function getOriginalValueFor(string $name, mixed $default = ''): mixed - { - return $this->get(self::ORIGINAL_VALUES)[$name] ?? $default; - } - - public function getPreviousUrl(): string - { - return $this->get(self::PREVIOUS_URL, default: ''); - } - - public function setPreviousUrl(string $url): void - { - $this->set(self::PREVIOUS_URL, $url); - } - /** * Retrieves the value for the given key and removes it from the session. */ - public function consume(string $key, mixed $default = null): mixed + public function consume(string|UnitEnum $key, mixed $default = null): mixed { + $key = Str\parse($key); $value = $this->get($key, $default); $this->remove($key); @@ -118,30 +89,61 @@ public function consume(string $key, mixed $default = null): mixed return $value; } + /** + * Retrieves all values from the session. + */ public function all(): array { - return $this->manager->all($this->id); + return $this->data; } - public function remove(string $key): void + /** + * Removes a value from the session. + */ + public function remove(string|UnitEnum $key): void { - $this->manager->remove($this->id, $key); + $key = Str\parse($key); + + if (isset($this->data[$key])) { + unset($this->data[$key]); + } } - public function destroy(): void + /** + * Cleans up expired session values. + */ + public function cleanup(): void + { + foreach ($this->expiredKeys as $key) { + $this->remove($key); + } + } + + /** + * Clears all values from the session. + */ + public function clear(): void { - $this->manager->destroy($this->id); + $this->data = []; } - public function isValid(): bool + public function __serialize(): array { - return $this->manager->isValid($this->id); + return [ + 'id' => (string) $this->id, + 'created_at' => $this->createdAt->getTimestamp()->getSeconds(), + 'last_active_at' => $this->lastActiveAt->getTimestamp()->getSeconds(), + 'data' => $this->data, + 'expired_keys' => $this->expiredKeys, + ]; } - public function cleanup(): void + public function __unserialize(array $data): void { - foreach ($this->expiredKeys as $key) { - $this->manager->remove($this->id, $key); - } + $this->id = new SessionId($data['id']); + $this->createdAt = DateTime::fromTimestamp($data['created_at']); + $this->lastActiveAt = DateTime::fromTimestamp($data['last_active_at']); + $this->data = $data['data']; + $this->expiredKeys = $data['expired_keys']; } } diff --git a/packages/http/src/Session/SessionCreated.php b/packages/http/src/Session/SessionCreated.php new file mode 100644 index 0000000000..6ac675988e --- /dev/null +++ b/packages/http/src/Session/SessionCreated.php @@ -0,0 +1,13 @@ +get(SessionManager::class); $sessionIdResolver = $container->get(SessionIdResolver::class); - return $sessionManager->create($sessionIdResolver->resolve()); + return $sessionManager->getOrCreate($sessionIdResolver->resolve()); } } diff --git a/packages/http/src/Session/SessionManager.php b/packages/http/src/Session/SessionManager.php index 577eaafc46..cc904b1d9b 100644 --- a/packages/http/src/Session/SessionManager.php +++ b/packages/http/src/Session/SessionManager.php @@ -6,19 +6,28 @@ interface SessionManager { - public function create(SessionId $id): Session; - - public function set(SessionId $id, string $key, mixed $value): void; - - public function get(SessionId $id, string $key, mixed $default = null): mixed; - - public function all(SessionId $id): array; - - public function remove(SessionId $id, string $key): void; - - public function destroy(SessionId $id): void; - - public function isValid(SessionId $id): bool; - - public function cleanup(): void; + /** + * Retrieves or creates a session based on its identifier. + */ + public function getOrCreate(SessionId $id): Session; + + /** + * Saves the session data to the server. + */ + public function save(Session $session): void; + + /** + * Removes the session from the server. + */ + public function delete(Session $session): void; + + /** + * Determines whether the session is still valid. + */ + public function isValid(Session $session): bool; + + /** + * Removes all expired sessions from the server. + */ + public function deleteExpiredSessions(): void; } diff --git a/packages/http/src/Session/TrackPreviousUrlMiddleware.php b/packages/http/src/Session/TrackPreviousUrlMiddleware.php new file mode 100644 index 0000000000..a9f4121f1e --- /dev/null +++ b/packages/http/src/Session/TrackPreviousUrlMiddleware.php @@ -0,0 +1,24 @@ +previousUrl->track($request); + + return $next($request); + } +} diff --git a/packages/http/src/Session/VerifyCsrfMiddleware.php b/packages/http/src/Session/VerifyCsrfMiddleware.php deleted file mode 100644 index d7b8029499..0000000000 --- a/packages/http/src/Session/VerifyCsrfMiddleware.php +++ /dev/null @@ -1,96 +0,0 @@ -cookies->add(new Cookie( - key: self::CSRF_COOKIE_KEY, - value: $this->session->token, - expiresAt: $this->clock->now()->plus($this->sessionConfig->expiration), - path: '/', - secure: Str\starts_with($this->appConfig->baseUri, 'https'), - )); - - if ($this->shouldSkipCheck($request)) { - return $next($request); - } - - $this->ensureTokenMatches($request); - - return $next($request); - } - - private function shouldSkipCheck(Request $request): bool - { - if (in_array($request->method, [Method::GET, Method::HEAD, Method::OPTIONS], strict: true)) { - return true; - } - - if ($this->environment->isTesting()) { - return true; - } - - return false; - } - - private function ensureTokenMatches(Request $request): void - { - $tokenFromRequest = $request->get( - key: Session::CSRF_TOKEN_KEY, - ); - - if (! $tokenFromRequest && $request->headers->has(self::CSRF_HEADER_KEY)) { - try { - $tokenFromRequest = $this->encrypter->decrypt( - urldecode($request->headers->get(self::CSRF_HEADER_KEY)), - ); - } catch (EncryptionException|JsonCouldNotBeDecoded) { - throw new CsrfTokenDidNotMatch(); - } - } - - if (! $tokenFromRequest) { - throw new CsrfTokenDidNotMatch(); - } - - if (! hash_equals($this->session->token, $tokenFromRequest)) { - throw new CsrfTokenDidNotMatch(); - } - } -} diff --git a/packages/http/src/Session/x-csrf-token.view.php b/packages/http/src/Session/x-csrf-token.view.php deleted file mode 100644 index f8ea7e763f..0000000000 --- a/packages/http/src/Session/x-csrf-token.view.php +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/packages/http/src/functions.php b/packages/http/src/functions.php index a3552f8ff2..5e28535395 100644 --- a/packages/http/src/functions.php +++ b/packages/http/src/functions.php @@ -1,14 +1,3 @@ token; -} diff --git a/packages/router/src/Exceptions/HtmlExceptionRenderer.php b/packages/router/src/Exceptions/HtmlExceptionRenderer.php index 6a72306257..af5c405828 100644 --- a/packages/router/src/Exceptions/HtmlExceptionRenderer.php +++ b/packages/router/src/Exceptions/HtmlExceptionRenderer.php @@ -3,6 +3,7 @@ namespace Tempest\Router\Exceptions; use Tempest\Auth\Exceptions\AccessWasDenied; +use Tempest\Container\Container; use Tempest\Core\Environment; use Tempest\Core\Priority; use Tempest\Http\ContentType; @@ -11,7 +12,7 @@ use Tempest\Http\Request; use Tempest\Http\Response; use Tempest\Http\SensitiveField; -use Tempest\Http\Session\CsrfTokenDidNotMatch; +use Tempest\Http\Session\FormSession; use Tempest\Http\Session\Session; use Tempest\Http\Status; use Tempest\Intl\Translator; @@ -33,7 +34,7 @@ public function __construct( private Translator $translator, private Request $request, private Validator $validator, - private Session $session, + private Container $container, ) {} public function canRender(Throwable $throwable, Request $request): bool @@ -46,7 +47,6 @@ public function render(Throwable $throwable): Response $response = match (true) { $throwable instanceof ValidationFailed => $this->renderValidationFailedResponse($throwable), $throwable instanceof AccessWasDenied => $this->renderErrorResponse(Status::FORBIDDEN, message: $throwable->accessDecision->message), - $throwable instanceof CsrfTokenDidNotMatch => $this->renderErrorResponse(Status::UNPROCESSABLE_CONTENT), $throwable instanceof HttpRequestFailed => $this->renderHttpRequestFailed($throwable), $throwable instanceof ConvertsToResponse => $throwable->convertToResponse(), default => $this->renderErrorResponse(Status::INTERNAL_SERVER_ERROR), @@ -125,8 +125,10 @@ private function renderValidationFailedResponse(ValidationFailed $exception): Re $status = Status::FOUND; } - $this->session->flash(Session::VALIDATION_ERRORS, $exception->failingRules); - $this->session->flash(Session::ORIGINAL_VALUES, $this->filterSensitiveFields($this->request, $exception->targetClass)); + if ($this->container->has(Session::class)) { + $this->container->get(FormSession::class)->setErrors($exception->failingRules); + $this->container->get(FormSession::class)->setOriginalValues($this->filterSensitiveFields($this->request, $exception->targetClass)); + } $errors = Arr\map_iterable($exception->failingRules, fn (array $failingRulesForField, string $field) => Arr\map_iterable( array: $failingRulesForField, diff --git a/packages/router/src/Exceptions/JsonExceptionRenderer.php b/packages/router/src/Exceptions/JsonExceptionRenderer.php index fd36610cbe..6e402684c3 100644 --- a/packages/router/src/Exceptions/JsonExceptionRenderer.php +++ b/packages/router/src/Exceptions/JsonExceptionRenderer.php @@ -11,7 +11,6 @@ use Tempest\Http\Response; use Tempest\Http\Responses\Json; use Tempest\Http\Responses\NotFound; -use Tempest\Http\Session\CsrfTokenDidNotMatch; use Tempest\Http\Status; use Tempest\Support\Arr; use Tempest\Validation\Exceptions\ValidationFailed; @@ -39,7 +38,6 @@ public function render(Throwable $throwable): Response return match (true) { $throwable instanceof ValidationFailed => $this->renderValidationFailedResponse($throwable), $throwable instanceof AccessWasDenied => $this->renderErrorResponse(Status::FORBIDDEN, message: $throwable->accessDecision->message), - $throwable instanceof CsrfTokenDidNotMatch => $this->renderErrorResponse(Status::UNPROCESSABLE_CONTENT), $throwable instanceof HttpRequestFailed => $this->renderHttpRequestFailed($throwable), $throwable instanceof ConvertsToResponse => $throwable->convertToResponse(), default => $this->renderErrorResponse(Status::INTERNAL_SERVER_ERROR, throwable: $throwable), diff --git a/packages/router/src/GenericResponseSender.php b/packages/router/src/GenericResponseSender.php index a1b9c963b6..472f46051a 100644 --- a/packages/router/src/GenericResponseSender.php +++ b/packages/router/src/GenericResponseSender.php @@ -70,7 +70,7 @@ private function resolveHeaders(Response $response): Generator if (is_array($response->body)) { $headers[ContentType::HEADER] ??= new Header(ContentType::HEADER); - $headers[ContentType::HEADER]->add(ContentType::JSON->value); + $headers[ContentType::HEADER]->add(ContentType::JSON); } foreach ($headers as $key => $header) { diff --git a/packages/router/src/GenericRouter.php b/packages/router/src/GenericRouter.php index c50ece2e14..5c87319d2a 100644 --- a/packages/router/src/GenericRouter.php +++ b/packages/router/src/GenericRouter.php @@ -6,14 +6,12 @@ use Psr\Http\Message\ServerRequestInterface as PsrRequest; use Tempest\Container\Container; -use Tempest\Core\AppConfig; use Tempest\Http\Mappers\PsrRequestToGenericRequestMapper; use Tempest\Http\Request; use Tempest\Http\Response; use Tempest\Http\Responses\Ok; use Tempest\Router\Exceptions\ControllerActionHadNoReturn; use Tempest\Router\Exceptions\MatchedRouteCouldNotBeResolved; -use Tempest\Router\Routing\Matching\RouteMatcher; use Tempest\View\View; use function Tempest\Mapper\map; @@ -22,8 +20,6 @@ { public function __construct( private Container $container, - private RouteMatcher $routeMatcher, - private AppConfig $appConfig, private RouteConfig $routeConfig, ) {} diff --git a/packages/router/src/HttpApplication.php b/packages/router/src/HttpApplication.php index faee9bc69a..a9e5cc2de5 100644 --- a/packages/router/src/HttpApplication.php +++ b/packages/router/src/HttpApplication.php @@ -10,7 +10,6 @@ use Tempest\Core\Kernel; use Tempest\Core\Tempest; use Tempest\Http\RequestFactory; -use Tempest\Http\Session\SessionManager; #[Singleton] final readonly class HttpApplication implements Application @@ -28,17 +27,13 @@ public static function boot(string $root, array $discoveryLocations = []): self public function run(): void { $router = $this->container->get(Router::class); - $psrRequest = $this->container->get(RequestFactory::class)->make(); - $responseSender = $this->container->get(ResponseSender::class); $responseSender->send( - $router->dispatch($psrRequest), + response: $router->dispatch($psrRequest), ); - $this->container->get(SessionManager::class)->cleanup(); - $this->container->get(Kernel::class)->shutdown(); } } diff --git a/packages/router/src/PreventCrossSiteRequestsMiddleware.php b/packages/router/src/PreventCrossSiteRequestsMiddleware.php new file mode 100644 index 0000000000..b1bf73f4fc --- /dev/null +++ b/packages/router/src/PreventCrossSiteRequestsMiddleware.php @@ -0,0 +1,85 @@ +shouldValidate($request)) { + return $next($request); + } + + if ($this->isValidRequest($request)) { + return $next($request); + } + + return new Forbidden(); + } + + /** + * Determines if the request should be validated for CSRF. + */ + private function shouldValidate(Request $request): bool + { + if (in_array($request->method, self::SAFE_METHODS, strict: true)) { + return false; + } + + return true; + } + + /** + * Validates the request using `Sec-Fetch-*` headers. + */ + private function isValidRequest(Request $request): bool + { + $secFetchSite = SecFetchSite::tryFrom($request->headers->get('sec-fetch-site', default: '')); + $secFetchMode = SecFetchMode::tryFrom($request->headers->get('sec-fetch-mode', default: '')); + + // prevent the request if there is no `sec-fetch-site` header + if ($secFetchSite === null) { + return false; + } + + // allow cross-site only on navigation requests + if ($secFetchSite === SecFetchSite::CROSS_SITE && $secFetchMode === SecFetchMode::NAVIGATE) { + return true; + } + + // same origin, same site and user-originated requests are always allowed + if ($secFetchSite !== SecFetchSite::CROSS_SITE) { + return true; + } + + return false; + } +} diff --git a/packages/router/src/RouteConfig.php b/packages/router/src/RouteConfig.php index e5044ebe6c..d2bf3f2912 100644 --- a/packages/router/src/RouteConfig.php +++ b/packages/router/src/RouteConfig.php @@ -31,7 +31,7 @@ public function __construct( public Middleware $middleware = new Middleware( HandleRouteExceptionMiddleware::class, MatchRouteMiddleware::class, - SetCookieMiddleware::class, + SetCookieHeadersMiddleware::class, HandleRouteSpecificMiddleware::class, ), ) {} diff --git a/packages/router/src/SecFetchMode.php b/packages/router/src/SecFetchMode.php new file mode 100644 index 0000000000..0e6767a4df --- /dev/null +++ b/packages/router/src/SecFetchMode.php @@ -0,0 +1,40 @@ +cookies->all() as $cookie) { - $cookieValue = $cookie->value === '' ? '' : $this->encrypter->encrypt($cookie->value)->serialize(); + $cookieValue = $cookie->value === '' + ? '' + : $this->encrypter->encrypt($cookie->value)->serialize(); + $response->addHeader('set-cookie', (string) $cookie->withValue($cookieValue)); } diff --git a/packages/router/src/SetCurrentUrlMiddleware.php b/packages/router/src/SetCurrentUrlMiddleware.php deleted file mode 100644 index 05089c3a7e..0000000000 --- a/packages/router/src/SetCurrentUrlMiddleware.php +++ /dev/null @@ -1,28 +0,0 @@ -method === Method::GET) { - $this->session->setPreviousUrl($request->uri); - } - - return $next($request); - } -} diff --git a/packages/router/src/Stateless.php b/packages/router/src/Stateless.php index 36804ea602..9f1c95d224 100644 --- a/packages/router/src/Stateless.php +++ b/packages/router/src/Stateless.php @@ -3,10 +3,10 @@ namespace Tempest\Router; use Attribute; -use Tempest\Http\Session\VerifyCsrfMiddleware; +use Tempest\Http\Session\ManageSessionMiddleware; /** - * Mark a route handler as stateless, causing all cookie- and session-related middleware to be skipped. + * Mark a route handler as stateless, causing all cookie and session-related middleware to be skipped. */ #[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] final class Stateless implements RouteDecorator @@ -15,9 +15,9 @@ public function decorate(Route $route): Route { $route->without = [ ...$route->without, - VerifyCsrfMiddleware::class, - SetCurrentUrlMiddleware::class, - SetCookieMiddleware::class, + PreventCrossSiteRequestsMiddleware::class, + ManageSessionMiddleware::class, + SetCookieHeadersMiddleware::class, ]; return $route; diff --git a/packages/support/src/Filesystem/LockType.php b/packages/support/src/Filesystem/LockType.php new file mode 100644 index 0000000000..6400a0e66e --- /dev/null +++ b/packages/support/src/Filesystem/LockType.php @@ -0,0 +1,32 @@ + fopen($filename, 'rb')); + + if ($handle === false) { + throw new Exceptions\RuntimeException(sprintf( + 'Failed to open file "%s": %s', + $filename, + $openMessage ?? 'internal error', + )); + } + + if (! flock($handle, $type->value)) { + fclose($handle); + + throw new Exceptions\RuntimeException(sprintf( + 'Failed to acquire lock on file "%s"', + $filename, + )); + } + + [$content, $readMessage] = box(static fn (): false|string => stream_get_contents($handle)); + + flock($handle, LOCK_UN); + fclose($handle); + + if ($content === false) { + throw new Exceptions\RuntimeException(sprintf( + 'Failed to read file "%s": %s', + $filename, + $readMessage ?? 'internal error', + )); + } + + return $content; +} + /** * Ensures that the specified directory exists. */ diff --git a/packages/support/tests/Filesystem/UnixFunctionsTest.php b/packages/support/tests/Filesystem/UnixFunctionsTest.php index 8308e7bf6a..5c233e0fea 100644 --- a/packages/support/tests/Filesystem/UnixFunctionsTest.php +++ b/packages/support/tests/Filesystem/UnixFunctionsTest.php @@ -15,6 +15,7 @@ use Tempest\Support\Filesystem\Exceptions\PathWasNotFound; use Tempest\Support\Filesystem\Exceptions\PathWasNotReadable; use Tempest\Support\Filesystem\Exceptions\RuntimeException; +use Tempest\Support\Filesystem\LockType; final class UnixFunctionsTest extends TestCase { @@ -737,6 +738,53 @@ public function read_file_not_found(): void Filesystem\read_file($file); } + #[Test] + public function read_file_locked(): void + { + $file = $this->fixtures . '/file.txt'; + + file_put_contents($file, 'Hello'); + + $content = Filesystem\read_locked_file($file); + + $this->assertEquals('Hello', $content); + } + + #[Test] + public function read_file_locked_with_exclusive_lock(): void + { + $file = $this->fixtures . '/file.txt'; + + file_put_contents($file, 'World'); + + $content = Filesystem\read_locked_file($file, LockType::EXCLUSIVE); + + $this->assertEquals('World', $content); + } + + #[Test] + public function read_file_locked_not_found(): void + { + $this->expectException(PathWasNotFound::class); + + $file = $this->fixtures . '/file.txt'; + + Filesystem\read_locked_file($file); + } + + #[Test] + public function read_file_locked_non_readable(): void + { + $this->expectException(PathWasNotReadable::class); + + $file = $this->fixtures . '/file.txt'; + + file_put_contents($file, 'Hello'); + chmod($file, 0o000); + + Filesystem\read_locked_file($file); + } + #[Test] public function ensure_directory_exists(): void { diff --git a/packages/view/src/Components/x-form.view.php b/packages/view/src/Components/x-form.view.php index cc0bd2b245..95bd8ee2a4 100644 --- a/packages/view/src/Components/x-form.view.php +++ b/packages/view/src/Components/x-form.view.php @@ -19,8 +19,6 @@ ?> - - diff --git a/packages/view/src/Components/x-input.view.php b/packages/view/src/Components/x-input.view.php index f9bd1f971c..2d867a307e 100644 --- a/packages/view/src/Components/x-input.view.php +++ b/packages/view/src/Components/x-input.view.php @@ -7,14 +7,14 @@ * @var string|null $default */ -use Tempest\Http\Session\Session; +use Tempest\Http\Session\FormSession; use Tempest\Validation\Validator; use function Tempest\get; use function Tempest\Support\str; -/** @var Session $session */ -$session = get(Session::class); +/** @var FormSession $formSession */ +$formSession = get(FormSession::class); /** @var Validator $validator */ $validator = get(Validator::class); @@ -24,8 +24,8 @@ $type ??= 'text'; $default ??= null; -$errors = $session->getErrorsFor($name); -$original = $session->getOriginalValueFor($name, $default); +$errors = $formSession->getErrorsFor($name); +$original = $formSession->getOriginalValueFor($name, $default); ?> diff --git a/src/Tempest/Framework/Testing/Http/HttpRouterTester.php b/src/Tempest/Framework/Testing/Http/HttpRouterTester.php index e83288f92a..01cb258bc0 100644 --- a/src/Tempest/Framework/Testing/Http/HttpRouterTester.php +++ b/src/Tempest/Framework/Testing/Http/HttpRouterTester.php @@ -4,6 +4,7 @@ namespace Tempest\Framework\Testing\Http; +use BackedEnum; use Laminas\Diactoros\ServerRequestFactory; use Psr\Http\Message\ServerRequestInterface as PsrRequest; use Tempest\Container\Container; @@ -14,6 +15,8 @@ use Tempest\Http\Request; use Tempest\Router\Exceptions\HttpExceptionHandler; use Tempest\Router\Router; +use Tempest\Router\SecFetchMode; +use Tempest\Router\SecFetchSite; use Tempest\Support\Uri; use Throwable; @@ -23,6 +26,8 @@ final class HttpRouterTester { private(set) ?ContentType $contentType = null; + private(set) bool $includeSecFetchHeaders = true; + public function __construct( private Container $container, ) {} @@ -37,6 +42,16 @@ public function as(ContentType $contentType): self return $this; } + /** + * Specifies that subsequent requests should be sent without Sec-Fetch headers. + */ + public function withoutSecFetchHeaders(): self + { + $this->includeSecFetchHeaders = false; + + return $this; + } + public function get(string $uri, array $query = [], array $headers = []): TestResponseHelper { return $this->sendRequest(new GenericRequest( @@ -49,14 +64,12 @@ public function get(string $uri, array $query = [], array $headers = []): TestRe public function head(string $uri, array $query = [], array $headers = []): TestResponseHelper { - return $this->sendRequest( - new GenericRequest( - method: Method::HEAD, - uri: Uri\merge_query($uri, ...$query), - body: [], - headers: $this->createHeaders($headers), - ), - ); + return $this->sendRequest(new GenericRequest( + method: Method::HEAD, + uri: Uri\merge_query($uri, ...$query), + body: [], + headers: $this->createHeaders($headers), + )); } public function post(string $uri, array $body = [], array $query = [], array $headers = []): TestResponseHelper @@ -163,8 +176,12 @@ public function makePsrRequest( $_SERVER['REQUEST_URI'] = $uri; $_SERVER['REQUEST_METHOD'] = $method->value; - foreach ($headers as $key => $value) { - $key = strtoupper($key); + foreach ($this->createHeaders($headers) as $key => $value) { + if ($value instanceof BackedEnum) { + $value = $value->value; + } + + $key = strtoupper(str_replace('-', '_', $key)); $_SERVER["HTTP_{$key}"] = $value; } @@ -186,6 +203,16 @@ private function createHeaders(array $headers = []): array $headers[$key ?? 'accept'] = $this->contentType->value; } + if ($this->includeSecFetchHeaders === true) { + if (! array_key_exists('sec-fetch-site', array_change_key_case($headers, case: CASE_LOWER))) { + $headers['sec-fetch-site'] = SecFetchSite::SAME_ORIGIN; + } + + if (! array_key_exists('sec-fetch-mode', array_change_key_case($headers, case: CASE_LOWER))) { + $headers['sec-fetch-mode'] = SecFetchMode::CORS; + } + } + return $headers; } } diff --git a/src/Tempest/Framework/Testing/Http/TestResponseHelper.php b/src/Tempest/Framework/Testing/Http/TestResponseHelper.php index 587b791cca..7915e7d04b 100644 --- a/src/Tempest/Framework/Testing/Http/TestResponseHelper.php +++ b/src/Tempest/Framework/Testing/Http/TestResponseHelper.php @@ -14,6 +14,7 @@ use Tempest\Http\Cookie\Cookie; use Tempest\Http\Request; use Tempest\Http\Response; +use Tempest\Http\Session\FormSession; use Tempest\Http\Session\Session; use Tempest\Http\Status; use Tempest\Support\Arr; @@ -261,6 +262,55 @@ public function assertDoesNotHaveCookie(string $key, null|string|Closure $value return $this; } + public function assertHasForm(Closure $closure): self + { + $this->assertHasContainer(); + + $formSession = $this->container->get(FormSession::class); + + if (false === $closure($formSession)) { + Assert::fail('Failed validating form session.'); + } + + return $this; + } + + /** + * Asserts that the original form values in the session match the given values. + */ + public function assertHasFormOriginalValues(array $values): self + { + $this->assertHasContainer(); + + $formSession = $this->container->get(FormSession::class); + $originalValues = $formSession->values(); + + foreach ($values as $key => $expectedValue) { + Assert::assertArrayHasKey( + key: $key, + array: $originalValues, + message: sprintf( + 'No original form value was set for [%s], available original form values: %s', + $key, + implode(', ', array_keys($originalValues)), + ), + ); + + Assert::assertEquals( + expected: $expectedValue, + actual: $originalValues[$key], + message: sprintf( + 'Original form value for [%s] does not match expected value. Expected: %s, Actual: %s', + $key, + var_export($expectedValue, return: true), + var_export($originalValues[$key], return: true), + ), + ); + } + + return $this; + } + public function assertHasSession(string $key, ?Closure $callback = null): self { $this->assertHasContainer(); @@ -311,8 +361,8 @@ public function assertHasValidationError(string $key, ?Closure $callback = null) public function assertHasNoValidationsErrors(): self { - $session = $this->container->get(Session::class); - $validationErrors = $session->get(Session::VALIDATION_ERRORS) ?? []; + $formSession = $this->container->get(FormSession::class); + $validationErrors = $formSession->getErrors(); Assert::assertEmpty( actual: $validationErrors, diff --git a/tests/Fixtures/Controllers/WithoutMiddlewareController.php b/tests/Fixtures/Controllers/WithoutMiddlewareController.php index b189e4b778..288b07a185 100644 --- a/tests/Fixtures/Controllers/WithoutMiddlewareController.php +++ b/tests/Fixtures/Controllers/WithoutMiddlewareController.php @@ -3,12 +3,11 @@ namespace Tests\Tempest\Fixtures\Controllers; use Tempest\Http\Responses\Ok; -use Tempest\Http\Session\VerifyCsrfMiddleware; use Tempest\Router\Get; -use Tempest\Router\SetCookieMiddleware; +use Tempest\Router\SetCookieHeadersMiddleware; use Tempest\Router\WithoutMiddleware; -#[WithoutMiddleware(VerifyCsrfMiddleware::class, SetCookieMiddleware::class)] +#[WithoutMiddleware(SetCookieHeadersMiddleware::class)] final class WithoutMiddlewareController { #[Get('/without-decorated-middleware')] diff --git a/tests/Integration/Http/CleanupSessionsCommandTest.php b/tests/Integration/Http/CleanupSessionsCommandTest.php index c8844c3c3a..ded6c52a9c 100644 --- a/tests/Integration/Http/CleanupSessionsCommandTest.php +++ b/tests/Integration/Http/CleanupSessionsCommandTest.php @@ -4,10 +4,13 @@ namespace Tests\Tempest\Integration\Http; +use PHPUnit\Framework\Attributes\Test; use Tempest\DateTime\Duration; +use Tempest\Http\Session\CleanupSessionsCommand; use Tempest\Http\Session\Config\FileSessionConfig; use Tempest\Http\Session\SessionId; use Tempest\Http\Session\SessionManager; +use Tempest\Support\Filesystem; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; use function Tempest\internal_storage_path; @@ -17,10 +20,10 @@ */ final class CleanupSessionsCommandTest extends FrameworkIntegrationTestCase { - public function test_destroy_sessions(): void + #[Test] + public function destroy_sessions(): void { - @unlink(internal_storage_path('/tests/sessions/session_a')); - @unlink(internal_storage_path('/tests/sessions/session_b')); + Filesystem\delete(internal_storage_path('/tests/sessions/')); $clock = $this->clock('2024-01-01 00:00:00'); @@ -31,16 +34,22 @@ public function test_destroy_sessions(): void $sessionManager = $this->container->get(SessionManager::class); - $sessionManager->set(new SessionId('session_a'), 'test', 'value'); + $sessionA = $sessionManager->getOrCreate(new SessionId('session_a')); + $sessionA->set('test', 'value'); - $clock->plus(9); + $sessionManager->save($sessionA); - $sessionManager->set(new SessionId('session_b'), 'test', 'value'); + $clock->plus(Duration::seconds(9)); - $clock->plus(2); + $sessionB = $sessionManager->getOrCreate(new SessionId('session_b')); + $sessionB->set('test', 'value'); + + $sessionManager->save($sessionB); + + $clock->plus(Duration::seconds(2)); $this->console - ->call('session:clean') + ->call(CleanupSessionsCommand::class) ->assertContains('session_a') ->assertDoesNotContain('session_b'); diff --git a/tests/Integration/Http/CookieManagerTest.php b/tests/Integration/Http/CookieManagerTest.php index 86971a7217..108b97095c 100644 --- a/tests/Integration/Http/CookieManagerTest.php +++ b/tests/Integration/Http/CookieManagerTest.php @@ -62,7 +62,7 @@ public function test_removing_a_cookie(): void $this->http ->get('/') ->assertOk() - ->assertHeaderContains('set-cookie', 'new=; Expires=Wed, 31-Dec-1969 23:59:59 GMT; Max-Age=0; Path=/'); + ->assertHeaderContains('set-cookie', 'new=; Expires=Wed, 31-Dec-1969 23:59:59 GMT; Max-Age=0; Path=/; Secure; SameSite=Lax'); } public function test_manually_adding_a_cookie(): void diff --git a/tests/Integration/Http/CsrfTest.php b/tests/Integration/Http/CsrfTest.php deleted file mode 100644 index fc23af6181..0000000000 --- a/tests/Integration/Http/CsrfTest.php +++ /dev/null @@ -1,154 +0,0 @@ -container->singleton(Environment::class, Environment::PRODUCTION); - } - - #[Test] - public function csrf_is_sent_as_cookie(): void - { - $token = $this->container->get(Session::class)->get(Session::CSRF_TOKEN_KEY); - - $this->http - ->get('/test') - ->assertHasCookie(VerifyCsrfMiddleware::CSRF_COOKIE_KEY, fn (string $value) => $value === $token); // @mago-expect lint:no-insecure-comparison - } - - #[TestWith([Method::POST])] - #[TestWith([Method::PUT])] - #[TestWith([Method::PATCH])] - #[TestWith([Method::DELETE])] - #[Test] - public function throws_when_missing_in_write_verbs(Method $method): void - { - $this->http - ->sendRequest(new GenericRequest($method, uri: '/test')) - ->assertStatus(Status::UNPROCESSABLE_CONTENT); - } - - #[TestWith([Method::GET])] - #[TestWith([Method::OPTIONS])] - #[TestWith([Method::HEAD])] - #[Test] - public function allows_missing_in_read_verbs(Method $method): void - { - $this->http - ->sendRequest(new GenericRequest($method, uri: '/test')) - ->assertOk(); - } - - #[Test] - public function throws_when_mismatch_from_body(): void - { - $this->container->get(Session::class)->set(Session::CSRF_TOKEN_KEY, 'abc'); - - $this->http - ->post('/test', [Session::CSRF_TOKEN_KEY => 'def']) - ->assertStatus(Status::UNPROCESSABLE_CONTENT); - } - - #[Test] - public function throws_when_mismatch_from_header(): void - { - $this->container->singleton(Environment::class, Environment::PRODUCTION); - $this->container->get(Session::class)->set(Session::CSRF_TOKEN_KEY, 'abc'); - - $this->http - ->post('/test', [Session::CSRF_TOKEN_KEY => 'def']) - ->assertStatus(Status::UNPROCESSABLE_CONTENT); - } - - #[Test] - public function matches_from_body(): void - { - $this->container->singleton(Environment::class, Environment::PRODUCTION); - - $session = $this->container->get(Session::class); - - $this->http - ->post('/test', [Session::CSRF_TOKEN_KEY => $session->token]) - ->assertOk(); - } - - #[Test] - public function matches_from_header_when_encrypted(): void - { - $this->container->singleton(Environment::class, Environment::PRODUCTION); - $session = $this->container->get(Session::class); - - // Encrypt the token as it would be in a real request - $sessionCookieValue = $this->container - ->get(Encrypter::class) - ->encrypt($session->token) - ->serialize(); - - $this->http - ->post('/test', headers: [VerifyCsrfMiddleware::CSRF_HEADER_KEY => $sessionCookieValue]) - ->assertOk(); - } - - #[Test] - public function csrf_component(): void - { - $session = $this->container->get(Session::class); - $session->set(Session::CSRF_TOKEN_KEY, 'test'); - - $rendered = $this->render(<< - HTML); - - $this->assertSame( - '', - $rendered, - ); - } - - #[Test] - public function csrf_token_function(): void - { - $session = $this->container->get(Session::class); - $session->set(Session::CSRF_TOKEN_KEY, 'test'); - - $this->assertSame('test', csrf_token()); - } - - #[Test] - public function csrf_with_cached_view(): void - { - $this->get(ViewCache::class)->enabled = true; - - $oldVersion = $this->render(<< - HTML); - - $session = $this->container->get(Session::class); - $session->destroy(); - - $newVersion = $this->render(<< - HTML); - - $this->assertNotSame($oldVersion, $newVersion); - } -} diff --git a/tests/Integration/Http/DatabaseSessionTest.php b/tests/Integration/Http/DatabaseSessionTest.php index 10afe6a6c7..17cb0457da 100644 --- a/tests/Integration/Http/DatabaseSessionTest.php +++ b/tests/Integration/Http/DatabaseSessionTest.php @@ -7,19 +7,19 @@ use PHPUnit\Framework\Attributes\PreCondition; use PHPUnit\Framework\Attributes\Test; use Tempest\Clock\Clock; -use Tempest\Database\Database; use Tempest\Database\Migrations\CreateMigrationsTable; use Tempest\DateTime\Duration; -use Tempest\EventBus\EventBus; use Tempest\Http\Session\Config\DatabaseSessionConfig; use Tempest\Http\Session\Installer\CreateSessionsTable; use Tempest\Http\Session\Managers\DatabaseSession; use Tempest\Http\Session\Managers\DatabaseSessionManager; use Tempest\Http\Session\Session; use Tempest\Http\Session\SessionConfig; -use Tempest\Http\Session\SessionDestroyed; +use Tempest\Http\Session\SessionCreated; +use Tempest\Http\Session\SessionDeleted; use Tempest\Http\Session\SessionId; use Tempest\Http\Session\SessionManager; +use Tempest\Support\Random; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; use function Tempest\Database\query; @@ -29,7 +29,11 @@ */ final class DatabaseSessionTest extends FrameworkIntegrationTestCase { - public Session $session { + private SessionManager $manager { + get => $this->container->get(SessionManager::class); + } + + private Session $session { get => $this->container->get(Session::class); } @@ -41,7 +45,6 @@ protected function configure(): void $this->container->singleton(SessionManager::class, fn () => new DatabaseSessionManager( $this->container->get(Clock::class), $this->container->get(SessionConfig::class), - $this->container->get(Database::class), )); $this->database->reset(migrate: false); @@ -49,25 +52,56 @@ protected function configure(): void } #[Test] - public function create_session_from_container(): void + public function get_or_create_creates_new_session(): void { - $this->assertInstanceOf(Session::class, $this->session); - $this->assertSessionExistsInDatabase($this->session->id); + $this->eventBus->preventEventHandling(); + + $sessionId = $this->createSessionId(); + $session = $this->manager->getOrCreate($sessionId); + + $this->assertInstanceOf(Session::class, $session); + $this->assertEquals($sessionId, $session->id); + + $this->manager->save($session); + $this->assertSessionExistsInDatabase($sessionId); + + $this->eventBus->assertDispatched( + event: SessionCreated::class, + callback: function (SessionCreated $event) use ($sessionId): void { + $this->assertEquals($sessionId, $event->session->id); + }, + count: 1, + ); + } + + #[Test] + public function get_or_create_loads_existing_session(): void + { + $sessionId = $this->createSessionId(); + $session = $this->manager->getOrCreate($sessionId); + $session->set('key', 'value'); + + $this->manager->save($session); + + $loaded = $this->manager->getOrCreate($sessionId); + + $this->assertEquals($sessionId, $loaded->id); + $this->assertTrue($session->createdAt->isSameMinute($loaded->createdAt)); + $this->assertEquals('value', $loaded->get('key')); } #[Test] - public function put_get(): void + public function save_persists_session_to_database(): void { $this->session->set('frieren', 'elf_mage'); + $this->manager->save($this->session); - $this->assertEquals('elf_mage', $this->session->get('frieren')); + $this->assertSessionExistsInDatabase($this->session->id); $this->assertSessionDataInDatabase($this->session->id, ['frieren' => 'elf_mage']); - - $this->assertEquals('deceased_hero', $this->session->get('himmel', 'deceased_hero')); } #[Test] - public function put_nested_data(): void + public function save_persists_nested_data(): void { $data = [ 'members' => ['Frieren', 'Fern', 'Stark'], @@ -80,93 +114,62 @@ public function put_nested_data(): void ]; $this->session->set('party', $data); + $this->manager->save($this->session); - $this->assertEquals($data, $this->session->get('party')); $this->assertSessionDataInDatabase($this->session->id, ['party' => $data]); } #[Test] - public function remove(): void + public function save_updates_last_active_timestamp(): void { - $this->session->set('spell', 'Zoltraak'); - $this->session->set('caster', 'Frieren'); + $clock = $this->clock('2025-01-01 00:00:00'); - $this->assertEquals('Zoltraak', $this->session->get('spell')); + $this->manager->save($this->session); + $originalTimestamp = $this->getSessionLastActiveTimestamp($this->session->id); - $this->session->remove('spell'); - - $this->assertNull($this->session->get('spell')); - $this->assertEquals('Frieren', $this->session->get('caster')); - $this->assertSessionDataInDatabase($this->session->id, ['caster' => 'Frieren']); - } - - #[Test] - public function all(): void - { - $data = [ - 'mage' => 'Frieren', - 'apprentice' => 'Fern', - 'warrior' => 'Stark', - ]; + $clock->plus(Duration::minutes(5)); - foreach ($data as $key => $value) { - $this->session->set($key, $value); - } + $this->session->set('action', 'spell_cast'); + $this->manager->save($this->session); + $updatedTimestamp = $this->getSessionLastActiveTimestamp($this->session->id); - $this->assertEquals($data, $this->session->all()); + $this->assertTrue($updatedTimestamp->after($originalTimestamp)); } #[Test] - public function destroy(): void + public function delete_removes_session_from_database(): void { - $manager = $this->container->get(SessionManager::class); - $sessionId = new SessionId('test_session_destroy'); + $this->eventBus->preventEventHandling(); - $session = $manager->create($sessionId); + $sessionId = $this->createSessionId(); + $session = $this->manager->getOrCreate($sessionId); $session->set('magic_type', 'offensive'); - $this->assertSessionExistsInDatabase($sessionId); + $this->manager->save($session); - $events = []; - $eventBus = $this->container->get(EventBus::class); - $eventBus->listen(function (SessionDestroyed $event) use (&$events): void { - $events[] = $event; - }); + $this->assertSessionExistsInDatabase($sessionId); - $session->destroy(); + $this->manager->delete($session); $this->assertSessionNotExistsInDatabase($sessionId); - $this->assertCount(1, $events); - $this->assertEquals((string) $sessionId, (string) $events[0]->id); - } - #[Test] - public function set_previous_url(): void - { - $session = $this->container->get(Session::class); - $session->setPreviousUrl('https://frieren.wiki/magic-academy'); - - $this->assertEquals('https://frieren.wiki/magic-academy', $session->getPreviousUrl()); - } - - #[Test] - public function is_valid(): void - { - $manager = $this->container->get(SessionManager::class); - $sessionId = new SessionId('new_session_validity'); - - $session = $manager->create($sessionId); - - $this->assertTrue($session->isValid()); - $this->assertTrue($manager->isValid($sessionId)); + $this->eventBus->assertDispatched( + event: SessionDeleted::class, + callback: function (SessionDeleted $event) use ($sessionId): void { + $this->assertEquals($sessionId, $event->id); + }, + count: 1, + ); } #[Test] - public function is_valid_for_unknown_session(): void + public function is_valid_checks_expiration(): void { - $manager = $this->container->get(SessionManager::class); + $sessionId = $this->createSessionId(); + $session = $this->manager->getOrCreate($sessionId); + $this->manager->save($session); - $this->assertFalse($manager->isValid(new SessionId('unknown_session'))); + $this->assertTrue($this->manager->isValid($session)); } #[Test] @@ -176,128 +179,66 @@ public function is_valid_for_expired_session(): void $this->container->config(new DatabaseSessionConfig(expiration: Duration::second())); - $manager = $this->container->get(SessionManager::class); - $sessionId = new SessionId('expired_session'); - - $session = $manager->create($sessionId); + $sessionId = $this->createSessionId(); + $session = $this->manager->getOrCreate($sessionId); + $this->manager->save($session); - $this->assertTrue($session->isValid()); + $this->assertTrue($this->manager->isValid($session)); $clock->plus(2); - $this->assertFalse($session->isValid()); - $this->assertFalse($manager->isValid($sessionId)); + $this->assertFalse($this->manager->isValid($session)); } #[Test] - public function session_expires_based_on_last_activity(): void + public function delete_expired_sessions_removes_old_records(): void { - $clock = $this->clock('2023-01-01 00:00:00'); + $this->eventBus->preventEventHandling(); - $this->container->config(new DatabaseSessionConfig(expiration: Duration::minutes(30))); - - $manager = $this->container->get(SessionManager::class); - $sessionId = new SessionId('last_activity_test'); - - // Create session - $session = $manager->create($sessionId); - $this->assertTrue($session->isValid()); - - $clock->plus(Duration::minutes(25)); - $this->assertTrue($session->isValid()); - - // Perform activity - $session->set('activity', 'user_action'); - $clock->plus(Duration::minutes(25)); - $this->assertTrue($session->isValid()); - $this->assertTrue($manager->isValid($sessionId)); - - // Move forward another 10 minutes, now 35 minutes from last activity - $clock->plus(Duration::minutes(10)); - $this->assertFalse($session->isValid()); - $this->assertFalse($manager->isValid($sessionId)); - } - - #[Test] - public function session_reflash(): void - { - $session = $this->container->get(Session::class); - - $session->flash('success', 'Spell learned: Zoltraak'); - - $this->assertEquals('Spell learned: Zoltraak', $session->get('success')); - - $session->reflash(); - $session->cleanup(); - - $this->assertEquals('Spell learned: Zoltraak', $session->get('success')); - } - - #[Test] - public function cleanup_removes_expired_sessions(): void - { - $clock = $this->clock('2023-01-01 00:00:00'); + $clock = $this->clock('2025-01-01 00:00:00'); $this->container->config(new DatabaseSessionConfig(expiration: Duration::minutes(30))); - $manager = $this->container->get(SessionManager::class); - - $activeSessionId = new SessionId('active_session'); - $activeSession = $manager->create($activeSessionId); - $activeSession->set('status', 'active'); - - $clock->minus(Duration::hour()); - $expiredSessionId = new SessionId('expired_session'); - $expiredSession = $manager->create($expiredSessionId); - $expiredSession->set('status', 'expired'); - - $clock->plus(Duration::hour()); + $activeId = $this->createSessionId(); + $active = $this->manager->getOrCreate($activeId); + $active->set('status', 'active'); - $this->assertSessionExistsInDatabase($activeSessionId); - $this->assertSessionExistsInDatabase($expiredSessionId); + $this->manager->save($active); - $manager->cleanup(); + $expiredId = $this->createSessionId(); + $expired = $this->manager->getOrCreate($expiredId); + $expired->set('status', 'expired'); - $this->assertSessionExistsInDatabase($activeSessionId); - $this->assertSessionNotExistsInDatabase($expiredSessionId); - } - - #[Test] - public function session_updates_last_active_timestamp(): void - { - $clock = $this->clock('2023-01-01 12:00:00'); - - $manager = $this->container->get(SessionManager::class); - $sessionId = new SessionId('timestamp_test'); + $this->manager->save($expired); - $session = $manager->create($sessionId); - $originalTimestamp = $this->getSessionLastActiveTimestamp($sessionId); + // expire the second session + $clock->plus(Duration::minutes(35)); - $clock->plus(Duration::minutes(5)); + // keep active session fresh + $this->manager->save($active); - $session->set('action', 'spell_cast'); - $updatedTimestamp = $this->getSessionLastActiveTimestamp($sessionId); + $this->assertSessionExistsInDatabase($activeId); + $this->assertSessionExistsInDatabase($expiredId); - $this->assertTrue($updatedTimestamp->after($originalTimestamp)); - } - - #[Test] - public function session_persists_csrf_token(): void - { - $session = $this->container->get(Session::class); - $token = $session->token; + $this->manager->deleteExpiredSessions(); - $data = $this->getSessionDataFromDatabase($session->id); + $this->assertSessionExistsInDatabase($activeId); + $this->assertSessionNotExistsInDatabase($expiredId); - $this->assertEquals($token, $data[Session::CSRF_TOKEN_KEY]); - $this->assertEquals($token, $session->token); + $this->eventBus->assertDispatched( + event: SessionDeleted::class, + callback: function (SessionDeleted $event) use ($expiredId): void { + $this->assertEquals($expiredId, $event->id); + }, + count: 1, + ); } private function assertSessionExistsInDatabase(SessionId $sessionId): void { $session = query(DatabaseSession::class) ->select() - ->where('session_id', (string) $sessionId) + ->where('id', (string) $sessionId) ->first(); $this->assertNotNull($session, "Session {$sessionId} should exist in database"); @@ -307,7 +248,7 @@ private function assertSessionNotExistsInDatabase(SessionId $sessionId): void { $session = query(DatabaseSession::class) ->select() - ->where('session_id', (string) $sessionId) + ->where('id', (string) $sessionId) ->first(); $this->assertNull($session, "Session {$sessionId} should not exist in database"); @@ -317,7 +258,7 @@ private function assertSessionDataInDatabase(SessionId $sessionId, array $data): { $session = query(DatabaseSession::class) ->select() - ->where('session_id', (string) $sessionId) + ->where('id', (string) $sessionId) ->first(); $this->assertNotNull($session, "Session {$sessionId} should exist in database"); @@ -329,25 +270,20 @@ private function assertSessionDataInDatabase(SessionId $sessionId, array $data): } } - private function getSessionDataFromDatabase(SessionId $sessionId): array - { - $session = query(DatabaseSession::class) - ->select() - ->where('session_id', (string) $sessionId) - ->first(); - - return unserialize($session->data ?? ''); - } - private function getSessionLastActiveTimestamp(SessionId $sessionId): \Tempest\DateTime\DateTime { $session = query(DatabaseSession::class) ->select() - ->where('session_id', (string) $sessionId) + ->where('id', (string) $sessionId) ->first(); $this->assertNotNull($session, "Session {$sessionId} should exist in database"); return $session->last_active_at; } + + private function createSessionId(): SessionId + { + return new SessionId(Random\uuid()); + } } diff --git a/tests/Integration/Http/Exceptions/HtmlExceptionRendererTest.php b/tests/Integration/Http/Exceptions/HtmlExceptionRendererTest.php index 2793273cf5..509393b7dc 100644 --- a/tests/Integration/Http/Exceptions/HtmlExceptionRendererTest.php +++ b/tests/Integration/Http/Exceptions/HtmlExceptionRendererTest.php @@ -13,7 +13,6 @@ use Tempest\Http\HttpRequestFailed; use Tempest\Http\Method; use Tempest\Http\Responses\NotFound; -use Tempest\Http\Session\CsrfTokenDidNotMatch; use Tempest\Http\Status; use Tempest\Intl\Catalog\Catalog; use Tempest\Intl\Locale; @@ -90,14 +89,6 @@ public function access_denied_with_custom_message(): void $this->assertSame('Access denied', $response->body->data['message']); } - #[Test] - public function csrf_mismatch(): void - { - $response = $this->renderer->render(new CsrfTokenDidNotMatch()); - - $this->assertSame(Status::UNPROCESSABLE_CONTENT, $response->status); - } - #[Test] public function http_request_failed(): void { diff --git a/tests/Integration/Http/Exceptions/HttpExceptionHandlerTest.php b/tests/Integration/Http/Exceptions/HttpExceptionHandlerTest.php index b52d60b3c9..370c7d9e1f 100644 --- a/tests/Integration/Http/Exceptions/HttpExceptionHandlerTest.php +++ b/tests/Integration/Http/Exceptions/HttpExceptionHandlerTest.php @@ -12,7 +12,6 @@ use Tempest\Http\HttpRequestFailed; use Tempest\Http\Response; use Tempest\Http\Responses\Redirect; -use Tempest\Http\Session\CsrfTokenDidNotMatch; use Tempest\Http\Status; use Tempest\Router\Exceptions\HttpExceptionHandler; use Tempest\Router\ResponseSender; @@ -147,16 +146,6 @@ public function test_exception_handler_runs_exception_processors(): void NullExceptionReporter::$exceptions = []; } - public function test_exception_handler_returns_unprocessable_for_csrf_mismatch(): void - { - $this->callExceptionHandler(function (): void { - $handler = $this->container->get(HttpExceptionHandler::class); - $handler->handle(new CsrfTokenDidNotMatch()); - }); - - $this->assertSame(Status::UNPROCESSABLE_CONTENT, $this->response->status); - } - private function callExceptionHandler(Closure $callback): void { try { diff --git a/tests/Integration/Http/Exceptions/JsonExceptionRendererTest.php b/tests/Integration/Http/Exceptions/JsonExceptionRendererTest.php index 933bba741f..292bdcd660 100644 --- a/tests/Integration/Http/Exceptions/JsonExceptionRendererTest.php +++ b/tests/Integration/Http/Exceptions/JsonExceptionRendererTest.php @@ -13,7 +13,6 @@ use Tempest\Http\Method; use Tempest\Http\Responses\Json; use Tempest\Http\Responses\NotFound; -use Tempest\Http\Session\CsrfTokenDidNotMatch; use Tempest\Http\Status; use Tempest\Router\Exceptions\JsonExceptionRenderer; use Tempest\Validation\Exceptions\ValidationFailed; @@ -91,15 +90,6 @@ public function access_denied_with_custom_message(): void $this->assertSame('Custom access denied message', $response->body['message']); } - #[Test] - public function csrf_mismatch(): void - { - $response = $this->renderer->render(new CsrfTokenDidNotMatch()); - - $this->assertInstanceOf(Json::class, $response); - $this->assertSame(Status::UNPROCESSABLE_CONTENT, $response->status); - } - #[Test] public function http_request_failed(): void { diff --git a/tests/Integration/Http/FileSessionTest.php b/tests/Integration/Http/FileSessionTest.php index 099a9eac61..8fef58cdf0 100644 --- a/tests/Integration/Http/FileSessionTest.php +++ b/tests/Integration/Http/FileSessionTest.php @@ -4,6 +4,9 @@ namespace Tests\Tempest\Integration\Http; +use PHPUnit\Framework\Attributes\PostCondition; +use PHPUnit\Framework\Attributes\PreCondition; +use PHPUnit\Framework\Attributes\Test; use Tempest\Clock\Clock; use Tempest\Core\FrameworkKernel; use Tempest\DateTime\Duration; @@ -11,13 +14,14 @@ use Tempest\Http\Session\Managers\FileSessionManager; use Tempest\Http\Session\Session; use Tempest\Http\Session\SessionConfig; +use Tempest\Http\Session\SessionCreated; +use Tempest\Http\Session\SessionDeleted; use Tempest\Http\Session\SessionId; use Tempest\Http\Session\SessionManager; use Tempest\Support\Filesystem; +use Tempest\Support\Path; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; -use function Tempest\Support\path; - /** * @internal */ @@ -25,118 +29,160 @@ final class FileSessionTest extends FrameworkIntegrationTestCase { private string $path = __DIR__ . '/Fixtures/tmp'; - protected function setUp(): void - { - parent::setUp(); + private SessionManager $manager { + get => $this->container->get(SessionManager::class); + } + private Session $session { + get => $this->container->get(Session::class); + } + + #[PreCondition] + protected function configure(): void + { Filesystem\ensure_directory_empty($this->path); $this->path = realpath($this->path); - $this->container->get(FrameworkKernel::class)->internalStorage = realpath($this->path); - $this->container->config(new FileSessionConfig(path: 'sessions', expiration: Duration::hours(2))); - $this->container->singleton( - SessionManager::class, - fn () => new FileSessionManager( - $this->container->get(Clock::class), - $this->container->get(SessionConfig::class), - ), - ); + + $this->container->config(new FileSessionConfig( + path: 'sessions', + expiration: Duration::hours(2), + )); + + $this->container->singleton(SessionManager::class, fn () => new FileSessionManager( + $this->container->get(Clock::class), + $this->container->get(SessionConfig::class), + )); } - protected function tearDown(): void + #[PostCondition] + protected function cleanup(): void { Filesystem\delete_directory($this->path); } - public function test_create_session_from_container(): void + #[Test] + public function get_or_create_creates_new_session(): void { - $session = $this->container->get(Session::class); + $this->eventBus->preventEventHandling(); + + $sessionId = new SessionId('new_session'); + $session = $this->manager->getOrCreate($sessionId); $this->assertInstanceOf(Session::class, $session); + $this->assertEquals($sessionId, $session->id); + + $this->eventBus->assertDispatched( + event: SessionCreated::class, + callback: function (SessionCreated $event) use ($sessionId): void { + $this->assertEquals($sessionId, $event->session->id); + }, + count: 1, + ); } - public function test_put_get(): void + #[Test] + public function get_or_create_loads_existing_session(): void { - $session = $this->container->get(Session::class); - - $session->set('test', 'value'); + $sessionId = new SessionId('existing_session'); + $session = $this->manager->getOrCreate($sessionId); + $session->set('key', 'value'); - $value = $session->get('test'); - $this->assertEquals('value', $value); - } - - public function test_remove(): void - { - $session = $this->container->get(Session::class); + $this->manager->save($session); - $session->set('test', 'value'); - $session->remove('test'); + $loaded = $this->manager->getOrCreate($sessionId); - $value = $session->get('test'); - $this->assertNull($value); + $this->assertEquals($sessionId, $loaded->id); + $this->assertTrue($session->createdAt->isSameMinute($loaded->createdAt)); + $this->assertEquals('value', $loaded->get('key')); } - public function test_destroy(): void + #[Test] + public function save_persists_session_to_file(): void { - $session = $this->container->get(Session::class); - $path = path($this->path, 'sessions', (string) $session->id)->toString(); + $this->session->set('test_key', 'test_value'); + $this->manager->save($this->session); + + $path = Path\normalize($this->path, 'sessions', (string) $this->session->id); $this->assertFileExists($path); - $session->destroy(); + $content = unserialize(file_get_contents($path)); - $this->assertFileDoesNotExist($path); + $this->assertInstanceOf(Session::class, $content); + $this->assertEquals('test_value', $content->get('test_key')); } - public function test_set_previous_url(): void + #[Test] + public function save_updates_last_active_timestamp(): void { - $session = $this->container->get(Session::class); - $session->setPreviousUrl('http://localhost/previous'); + $clock = $this->clock('2025-01-01 00:00:00'); + $original = $this->session->lastActiveAt; - $this->assertEquals('http://localhost/previous', $session->getPreviousUrl()); + // session created with current timestamp + $this->assertTrue($this->session->lastActiveAt->equals($clock->now())); + + // save it 5 minutes later + $clock->plus(Duration::minutes(5)); + $this->manager->save($this->session); + + // last active at has updated + $this->assertTrue($this->session->lastActiveAt->after($original)); } - public function test_is_valid(): void + #[Test] + public function delete_removes_session_file(): void { - $clock = $this->clock('2023-01-01 00:00:00'); + $this->eventBus->preventEventHandling(); - $this->container->config(new FileSessionConfig( - path: 'test_sessions', - expiration: Duration::second(), - )); + $this->manager->save($this->session); - $sessionManager = $this->container->get(SessionManager::class); + $path = Path\normalize($this->path, 'sessions', (string) $this->session->id); - $this->assertFalse($sessionManager->isValid(new SessionId('unknown'))); - - $session = $sessionManager->create(new SessionId('new')); + $this->assertFileExists($path); - $this->assertTrue($session->isValid()); + $this->manager->delete($this->session); - $clock->plus(1); + $this->assertFileDoesNotExist($path); - $this->assertFalse($session->isValid()); + $this->eventBus->assertDispatched( + event: SessionDeleted::class, + callback: function (SessionDeleted $event): void { + $this->assertEquals($this->session->id, $event->id); + }, + count: 1, + ); } - public function test_session_reflash(): void + #[Test] + public function is_valid_checks_expiration(): void { - $session = $this->container->get(Session::class); + $clock = $this->clock('2025-01-01 00:00:00'); - $session->flash('test', 'value'); - $session->flash('test2', ['key' => 'value']); + $this->container->config(new FileSessionConfig( + path: 'test_sessions', + expiration: Duration::seconds(10), + )); + + $session = $this->manager->getOrCreate(new SessionId('expiration_test')); - $this->assertEquals('value', $session->get('test')); + $this->manager->save($session); - $session->reflash(); - $session->cleanup(); + $this->assertTrue($this->manager->isValid($session)); - $this->assertEquals('value', $session->get('test')); - $this->assertEquals(['key' => 'value'], $session->get('test2')); + $clock->plus(Duration::seconds(5)); + $this->assertTrue($this->manager->isValid($session)); + + $clock->plus(Duration::seconds(6)); + $this->assertFalse($this->manager->isValid($session)); } - public function test_session_expires_based_on_last_activity(): void + #[Test] + public function delete_expired_sessions_removes_old_files(): void { + $this->eventBus->preventEventHandling(); + $clock = $this->clock('2023-01-01 00:00:00'); $this->container->config(new FileSessionConfig( @@ -144,25 +190,35 @@ public function test_session_expires_based_on_last_activity(): void expiration: Duration::minutes(30), )); - $manager = $this->container->get(SessionManager::class); - $sessionId = new SessionId('last_activity_test'); + $active = $this->manager->getOrCreate(new SessionId('active')); + $this->manager->save($active); - // Create session - $session = $manager->create($sessionId); - $this->assertTrue($session->isValid()); + $expired = $this->manager->getOrCreate(new SessionId('expired')); + $this->manager->save($expired); - $clock->plus(Duration::minutes(25)); - $this->assertTrue($session->isValid()); + // we expire the $expired one + $clock->plus(Duration::minutes(35)); - // Perform activity - $session->set('activity', 'user_action'); - $clock->plus(Duration::minutes(25)); - $this->assertTrue($session->isValid()); - $this->assertTrue($manager->isValid($sessionId)); + // keep active session fresh + $this->manager->save($active); - // Move forward another 10 minutes, now 35 minutes from last activity - $clock->plus(Duration::minutes(10)); - $this->assertFalse($session->isValid()); - $this->assertFalse($manager->isValid($sessionId)); + $activePath = Path\normalize($this->path, 'test_sessions', (string) $active->id); + $expiredPath = Path\normalize($this->path, 'test_sessions', (string) $expired->id); + + $this->assertFileExists($activePath); + $this->assertFileExists($expiredPath); + + $this->manager->deleteExpiredSessions(); + + $this->assertFileExists($activePath); + $this->assertFileDoesNotExist($expiredPath); + + $this->eventBus->assertDispatched( + event: SessionDeleted::class, + callback: function (SessionDeleted $event) use ($expired): void { + $this->assertEquals($expired->id, $event->id); + }, + count: 1, + ); } } diff --git a/tests/Integration/Http/FormSessionTest.php b/tests/Integration/Http/FormSessionTest.php new file mode 100644 index 0000000000..788fc00691 --- /dev/null +++ b/tests/Integration/Http/FormSessionTest.php @@ -0,0 +1,192 @@ + $this->container->get(FormSession::class); + } + + private Session $session { + get => $this->container->get(Session::class); + } + + #[Test] + public function flash_errors_stores_errors(): void + { + $errors = [ + 'name' => [new FailingRule(new HasLength(min: 3))], + 'email' => [new FailingRule(new HasLength(min: 5))], + ]; + + $this->formSession->setErrors($errors); + + $this->assertEquals($errors, $this->formSession->getErrors()); + } + + #[Test] + public function errors_for_returns_field_specific_errors(): void + { + $nameError = new FailingRule(new HasLength(min: 3)); + $emailError = new FailingRule(new HasLength(min: 5)); + + $this->formSession->setErrors([ + 'name' => [$nameError], + 'email' => [$emailError], + ]); + + $this->assertEquals([$nameError], $this->formSession->getErrorsFor('name')); + $this->assertEquals([$emailError], $this->formSession->getErrorsFor('email')); + } + + #[Test] + public function errors_for_returns_empty_array_when_field_has_no_errors(): void + { + $this->formSession->setErrors([ + 'name' => [new FailingRule(new HasLength(min: 3))], + ]); + + $this->assertEquals([], $this->formSession->getErrorsFor('email')); + } + + #[Test] + public function has_errors_returns_true_when_errors_exist(): void + { + $this->formSession->setErrors([ + 'name' => [new FailingRule(new HasLength(min: 3))], + ]); + + $this->assertTrue($this->formSession->hasErrors()); + } + + #[Test] + public function has_errors_returns_false_when_no_errors(): void + { + $this->assertFalse($this->formSession->hasErrors()); + } + + #[Test] + public function has_error_returns_true_when_field_has_errors(): void + { + $this->formSession->setErrors([ + 'name' => [new FailingRule(new HasLength(min: 3))], + ]); + + $this->assertTrue($this->formSession->hasError('name')); + } + + #[Test] + public function has_error_returns_false_when_field_has_no_errors(): void + { + $this->formSession->setErrors([ + 'name' => [new FailingRule(new HasLength(min: 3))], + ]); + + $this->assertFalse($this->formSession->hasError('email')); + } + + #[Test] + public function values_returns_empty_array_when_no_values(): void + { + $this->assertEquals([], $this->formSession->values()); + } + + #[Test] + public function flash_values_stores_values(): void + { + $values = [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]; + + $this->formSession->setOriginalValues($values); + + $this->assertEquals($values, $this->formSession->values()); + } + + #[Test] + public function value_returns_field_specific_value(): void + { + $this->formSession->setOriginalValues([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]); + + $this->assertEquals('John Doe', $this->formSession->getOriginalValueFor('name')); + $this->assertEquals('john@example.com', $this->formSession->getOriginalValueFor('email')); + } + + #[Test] + public function value_returns_default_when_field_not_found(): void + { + $this->formSession->setOriginalValues([ + 'name' => 'John Doe', + ]); + + $this->assertEquals('', $this->formSession->getOriginalValueFor('email')); + $this->assertEquals('default', $this->formSession->getOriginalValueFor('email', 'default')); + } + + #[Test] + public function clear_removes_errors_and_values(): void + { + $this->formSession->setErrors([ + 'name' => [new FailingRule(new HasLength(min: 3))], + ]); + $this->formSession->setOriginalValues([ + 'name' => 'John', + ]); + + $this->formSession->clear(); + + $this->assertEquals([], $this->formSession->getErrors()); + $this->assertEquals([], $this->formSession->values()); + } + + #[Test] + public function errors_are_flashed_and_cleared_after_next_request(): void + { + $this->formSession->setErrors([ + 'name' => [new FailingRule(new HasLength(min: 3))], + ]); + + // First access - errors exist + $this->assertTrue($this->formSession->hasErrors()); + + // Simulate cleanup after request + $this->session->cleanup(); + + // Second access - errors cleared + $this->assertFalse($this->formSession->hasErrors()); + } + + #[Test] + public function values_are_flashed_and_cleared_after_next_request(): void + { + $this->formSession->setOriginalValues([ + 'name' => 'John Doe', + ]); + + // First access - values exist + $this->assertEquals('John Doe', $this->formSession->getOriginalValueFor('name')); + + // Simulate cleanup after request + $this->session->cleanup(); + + // Second access - values cleared + $this->assertEquals('', $this->formSession->getOriginalValueFor('name')); + } +} diff --git a/tests/Integration/Http/PreviousUrlTest.php b/tests/Integration/Http/PreviousUrlTest.php new file mode 100644 index 0000000000..e15651f48e --- /dev/null +++ b/tests/Integration/Http/PreviousUrlTest.php @@ -0,0 +1,138 @@ + $this->container->get(PreviousUrl::class); + } + + private Session $session { + get => $this->container->get(Session::class); + } + + #[Test] + public function tracks_get_requests(): void + { + $this->previousUrl->track(new GenericRequest( + method: Method::GET, + uri: '/dashboard', + )); + + $this->assertEquals('/dashboard', $this->previousUrl->get()); + } + + #[Test] + public function does_not_track_post_requests(): void + { + $this->previousUrl->track(new GenericRequest( + method: Method::POST, + uri: '/submit-form', + )); + + $this->assertEquals('/', $this->previousUrl->get()); + } + + #[Test] + public function does_not_track_ajax_requests(): void + { + $this->previousUrl->track(new GenericRequest( + method: Method::GET, + uri: '/api/data', + headers: ['X-Requested-With' => 'XMLHttpRequest'], + )); + + $this->assertEquals('/', $this->previousUrl->get()); + } + + #[Test] + public function does_not_track_prefetch_requests(): void + { + $this->previousUrl->track(new GenericRequest( + method: Method::GET, + uri: '/prefetch-page', + headers: ['Purpose' => 'prefetch'], + )); + + $this->assertEquals('/', $this->previousUrl->get()); + } + + #[Test] + public function get_returns_default_when_no_previous_url(): void + { + $this->assertEquals('/', $this->previousUrl->get()); + $this->assertEquals('/home', $this->previousUrl->get('/home')); + } + + #[Test] + public function updates_previous_url_on_subsequent_tracks(): void + { + $this->previousUrl->track(new GenericRequest(method: Method::GET, uri: '/page1')); + $this->assertEquals('/page1', $this->previousUrl->get()); + + $this->previousUrl->track(new GenericRequest(method: Method::GET, uri: '/page2')); + $this->assertEquals('/page2', $this->previousUrl->get()); + + $this->previousUrl->track(new GenericRequest(method: Method::GET, uri: '/page3')); + $this->assertEquals('/page3', $this->previousUrl->get()); + } + + #[Test] + public function set_intended_stores_url(): void + { + $this->previousUrl->setIntended('/protected-page'); + + $this->assertEquals('/protected-page', $this->session->get('#intended_url')); + } + + #[Test] + public function get_intended_returns_and_removes_url(): void + { + $this->previousUrl->setIntended('/admin/dashboard'); + + $this->assertEquals('/admin/dashboard', $this->previousUrl->getIntended()); + $this->assertEquals('/', $this->previousUrl->getIntended()); + } + + #[Test] + public function get_intended_returns_default_when_not_set(): void + { + $this->assertEquals('/', $this->previousUrl->getIntended()); + $this->assertEquals('/fallback', $this->previousUrl->getIntended('/fallback')); + } + + #[Test] + public function tracks_urls_with_query_strings(): void + { + $this->previousUrl->track(new GenericRequest( + method: Method::GET, + uri: '/search?q=tempest&filter=docs', + )); + + $this->assertEquals('/search?q=tempest&filter=docs', $this->previousUrl->get()); + } + + #[Test] + public function tracks_urls_with_fragments(): void + { + $this->previousUrl->track(new GenericRequest( + method: Method::GET, + uri: '/docs#installation', + )); + + $this->assertEquals('/docs#installation', $this->previousUrl->get()); + } +} diff --git a/tests/Integration/Http/RedisSessionTest.php b/tests/Integration/Http/RedisSessionTest.php index 9dceeb406e..ac19c832e0 100644 --- a/tests/Integration/Http/RedisSessionTest.php +++ b/tests/Integration/Http/RedisSessionTest.php @@ -4,19 +4,21 @@ namespace Tests\Tempest\Integration\Http; +use PHPUnit\Framework\Attributes\PostCondition; +use PHPUnit\Framework\Attributes\PreCondition; use PHPUnit\Framework\Attributes\Test; use Tempest\Clock\Clock; use Tempest\DateTime\DateTimeInterface; use Tempest\DateTime\Duration; -use Tempest\EventBus\EventBus; use Tempest\Http\Session\Config\RedisSessionConfig; use Tempest\Http\Session\Managers\RedisSessionManager; use Tempest\Http\Session\Session; -use Tempest\Http\Session\SessionConfig; -use Tempest\Http\Session\SessionDestroyed; +use Tempest\Http\Session\SessionCreated; +use Tempest\Http\Session\SessionDeleted; use Tempest\Http\Session\SessionId; use Tempest\Http\Session\SessionManager; use Tempest\KeyValue\Redis\Redis; +use Tempest\Support\Random; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; use Throwable; @@ -25,19 +27,27 @@ */ final class RedisSessionTest extends FrameworkIntegrationTestCase { - protected function setUp(): void + private SessionManager $manager { + get => $this->container->get(SessionManager::class); + } + + private Session $session { + get => $this->container->get(Session::class); + } + + #[PreCondition] + protected function configure(): void { - parent::setUp(); - - $this->container->config(new RedisSessionConfig(expiration: Duration::hours(2), prefix: 'test_session:')); - $this->container->singleton( - SessionManager::class, - fn () => new RedisSessionManager( - $this->container->get(Clock::class), - $this->container->get(Redis::class), - $this->container->get(SessionConfig::class), - ), - ); + $this->container->config(new RedisSessionConfig( + expiration: Duration::hours(2), + prefix: 'test_session:', + )); + + $this->container->singleton(SessionManager::class, fn () => new RedisSessionManager( + clock: $this->container->get(Clock::class), + redis: $this->container->get(Redis::class), + config: $this->container->get(RedisSessionConfig::class), + )); try { $this->container->get(Redis::class)->connect(); @@ -46,7 +56,8 @@ protected function setUp(): void } } - protected function tearDown(): void + #[PostCondition] + protected function cleanup(): void { try { $this->container->get(Redis::class)->flush(); @@ -55,225 +66,217 @@ protected function tearDown(): void } #[Test] - public function create_session_from_container(): void + public function get_or_create_creates_new_session(): void { - $session = $this->container->get(Session::class); + $this->eventBus->preventEventHandling(); + + $sessionId = $this->createSessionId(); + $session = $this->manager->getOrCreate($sessionId); + $this->manager->save($session); $this->assertInstanceOf(Session::class, $session); + $this->assertEquals($sessionId, $session->id); + + $this->eventBus->assertDispatched( + event: SessionCreated::class, + callback: function (SessionCreated $event) use ($sessionId): void { + $this->assertEquals($sessionId, $event->session->id); + }, + count: 1, + ); } #[Test] - public function put_get(): void + public function get_or_create_loads_existing_session(): void { - $session = $this->container->get(Session::class); - - $session->set('test', 'value'); - - $value = $session->get('test'); - $this->assertEquals('value', $value); - } + $sessionId = $this->createSessionId(); + $session = $this->manager->getOrCreate($sessionId); + $session->set('key', 'value'); - #[Test] - public function remove(): void - { - $session = $this->container->get(Session::class); + $this->manager->save($session); - $session->set('test', 'value'); - $session->remove('test'); + $loaded = $this->manager->getOrCreate($sessionId); - $value = $session->get('test'); - $this->assertNull($value); + $this->assertEquals($sessionId, $loaded->id); + $this->assertTrue($session->createdAt->isSameMinute($loaded->createdAt)); + $this->assertEquals('value', $loaded->get('key')); } #[Test] - public function destroy(): void + public function save_persists_session_to_redis(): void { - $manager = $this->container->get(SessionManager::class); - $sessionId = new SessionId('test_session_destroy'); - - $session = $manager->create($sessionId); - $session->set('magic_type', 'offensive'); - - $this->assertTrue($manager->isValid($sessionId)); - - $events = []; - $eventBus = $this->container->get(EventBus::class); - $eventBus->listen(function (SessionDestroyed $event) use (&$events): void { - $events[] = $event; - }); + $this->session->set('frieren', 'elf_mage'); + $this->manager->save($this->session); - $session->destroy(); - - $this->assertFalse($manager->isValid($sessionId)); - $this->assertCount(1, $events); - $this->assertEquals((string) $sessionId, (string) $events[0]->id); + $this->assertSessionExistsInRedis($this->session->id); + $this->assertSessionDataInRedis($this->session->id, ['frieren' => 'elf_mage']); } #[Test] - public function set_previous_url(): void + public function save_persists_nested_data(): void { - $session = $this->container->get(Session::class); - $session->setPreviousUrl('http://localhost/previous'); - - $this->assertEquals('http://localhost/previous', $session->getPreviousUrl()); + $data = [ + 'members' => ['Frieren', 'Fern', 'Stark'], + 'location' => 'Northern Plateau', + 'quest' => [ + 'name' => 'Journey to Ende', + 'progress' => 0.75, + ], + ]; + + $this->session->set('party', $data); + $this->manager->save($this->session); + + $this->assertSessionDataInRedis($this->session->id, ['party' => $data]); } #[Test] - public function is_valid(): void + public function save_updates_last_active_timestamp(): void { - $clock = $this->clock('2023-01-01 00:00:00'); - - $this->container->config(new RedisSessionConfig( - expiration: Duration::second(), - prefix: 'test_session:', - )); + $clock = $this->clock('2025-01-01 00:00:00'); - $sessionManager = $this->container->get(SessionManager::class); + $this->manager->save($this->session); + $originalTimestamp = $this->getSessionLastActiveTimestamp($this->session->id); - $this->assertFalse($sessionManager->isValid(new SessionId('unknown'))); - - $session = $sessionManager->create(new SessionId('new')); - - $this->assertTrue($session->isValid()); + $clock->plus(Duration::minutes(5)); - $clock->plus(1); + $this->session->set('action', 'spell_cast'); + $this->manager->save($this->session); + $updatedTimestamp = $this->getSessionLastActiveTimestamp($this->session->id); - $this->assertFalse($session->isValid()); + $this->assertTrue($updatedTimestamp->after($originalTimestamp)); } #[Test] - public function session_reflash(): void + public function delete_removes_session_from_redis(): void { - $session = $this->container->get(Session::class); + $this->eventBus->preventEventHandling(); + + $sessionId = $this->createSessionId(); + $session = $this->manager->getOrCreate($sessionId); + $session->set('magic_type', 'offensive'); + + $this->manager->save($session); - $session->flash('test', 'value'); - $session->flash('test2', ['key' => 'value']); + $this->assertSessionExistsInRedis($sessionId); - $this->assertEquals('value', $session->get('test')); + $this->manager->delete($session); - $session->reflash(); - $session->cleanup(); + $this->assertSessionNotExistsInRedis($sessionId); - $this->assertEquals('value', $session->get('test')); - $this->assertEquals(['key' => 'value'], $session->get('test2')); + $this->eventBus->assertDispatched( + event: SessionDeleted::class, + callback: function (SessionDeleted $event) use ($sessionId): void { + $this->assertEquals($sessionId, $event->id); + }, + count: 1, + ); } #[Test] - public function session_expires_based_on_last_activity(): void + public function is_valid_checks_expiration(): void { $clock = $this->clock('2023-01-01 00:00:00'); $this->container->config(new RedisSessionConfig( - expiration: Duration::minutes(30), + expiration: Duration::seconds(10), prefix: 'test_session:', )); - $manager = $this->container->get(SessionManager::class); - $sessionId = new SessionId('last_activity_test'); + $session = $this->manager->getOrCreate(new SessionId('expiration_test')); + $this->manager->save($session); - // Create session - $session = $manager->create($sessionId); - $this->assertTrue($session->isValid()); + $this->assertTrue($this->manager->isValid($session)); - $clock->plus(Duration::minutes(25)); - $this->assertTrue($session->isValid()); + $clock->plus(Duration::seconds(5)); + $this->assertTrue($this->manager->isValid($session)); - // Perform activity - $session->set('activity', 'user_action'); - $clock->plus(Duration::minutes(25)); - $this->assertTrue($session->isValid()); - $this->assertTrue($manager->isValid($sessionId)); - - // Move forward another 10 minutes, now 35 minutes from last activity - $clock->plus(Duration::minutes(10)); - $this->assertFalse($session->isValid()); - $this->assertFalse($manager->isValid($sessionId)); + $clock->plus(Duration::seconds(6)); + $this->assertFalse($this->manager->isValid($session)); } #[Test] - public function cleanup_removes_expired_sessions(): void + public function delete_expired_sessions_removes_old_records(): void { - $clock = $this->clock('2023-01-01 00:00:00'); + $this->eventBus->preventEventHandling(); - $this->container->config(new RedisSessionConfig(expiration: Duration::minutes(30), prefix: 'test_session:')); - - $manager = $this->container->get(SessionManager::class); - - $activeSessionId = new SessionId('active_session'); - $activeSession = $manager->create($activeSessionId); - $activeSession->set('status', 'active'); + $clock = $this->clock('2023-01-01 00:00:00'); - $clock->minus(Duration::hour()); - $expiredSessionId = new SessionId('expired_session'); - $expiredSession = $manager->create($expiredSessionId); - $expiredSession->set('status', 'expired'); + $this->container->config(new RedisSessionConfig( + expiration: Duration::minutes(30), + prefix: 'test_session:', + )); - $clock->plus(Duration::hour()); + $activeSessionId = $this->createSessionId(); + $active = $this->manager->getOrCreate($activeSessionId); + $active->set('status', 'active'); - $this->assertSessionExistsInDatabase($activeSessionId); - $this->assertSessionExistsInDatabase($expiredSessionId); + $this->manager->save($active); - $manager->cleanup(); + $expiredSessionId = $this->createSessionId(); + $expired = $this->manager->getOrCreate($expiredSessionId); + $expired->set('status', 'expired'); - $this->assertSessionExistsInDatabase($activeSessionId); - $this->assertSessionNotExistsInDatabase($expiredSessionId); - } + $this->manager->save($expired); - #[Test] - public function session_updates_last_active_timestamp(): void - { - $clock = $this->clock('2023-01-01 12:00:00'); + // expire the $expired one + $clock->plus(Duration::minutes(35)); - $manager = $this->container->get(SessionManager::class); - $sessionId = new SessionId('timestamp_test'); + // keep the first one active + $this->manager->save($active); - $session = $manager->create($sessionId); - $originalTimestamp = $this->getSessionLastActiveTimestamp($sessionId); + $this->assertSessionExistsInRedis($activeSessionId); + $this->assertSessionExistsInRedis($expiredSessionId); - $clock->plus(Duration::minutes(5)); + $this->manager->deleteExpiredSessions(); - $session->set('action', 'spell_cast'); - $updatedTimestamp = $this->getSessionLastActiveTimestamp($sessionId); + $this->assertSessionExistsInRedis($activeSessionId); + $this->assertSessionNotExistsInRedis($expiredSessionId); - $this->assertTrue($updatedTimestamp->after($originalTimestamp)); + $this->eventBus->assertDispatched( + event: SessionDeleted::class, + callback: function (SessionDeleted $event) use ($expiredSessionId): void { + $this->assertEquals($expiredSessionId, $event->id); + }, + count: 1, + ); } - #[Test] - public function session_persists_csrf_token(): void + private function assertSessionExistsInRedis(SessionId $sessionId): void { - $session = $this->container->get(Session::class); - $token = $session->token; + $session = $this->getSessionFromRedis($sessionId); - $data = $this->getSessionDataFromDatabase($session->id); - - $this->assertEquals($token, $data[Session::CSRF_TOKEN_KEY]); - $this->assertEquals($token, $session->token); + $this->assertNotNull($session, "Session {$sessionId} should exist in Redis"); } - private function assertSessionExistsInDatabase(SessionId $sessionId): void + private function assertSessionNotExistsInRedis(SessionId $sessionId): void { - $session = $this->getSessionFromDatabase($sessionId); + $session = $this->getSessionFromRedis($sessionId); - $this->assertNotNull($session, "Session {$sessionId} should exist in database"); + $this->assertNull($session, "Session {$sessionId} should not exist in Redis"); } - private function assertSessionNotExistsInDatabase(SessionId $sessionId): void + private function assertSessionDataInRedis(SessionId $sessionId, array $data): void { - $session = $this->getSessionFromDatabase($sessionId); + $session = $this->getSessionFromRedis($sessionId); + + $this->assertNotNull($session, "Session {$sessionId} should exist in Redis"); - $this->assertNull($session, "Session {$sessionId} should not exist in database"); + foreach ($data as $key => $value) { + $this->assertEquals($value, $session->data[$key], "Session data key '{$key}' should match expected value"); + } } private function getSessionLastActiveTimestamp(SessionId $sessionId): DateTimeInterface { - $session = $this->getSessionFromDatabase($sessionId); + $session = $this->getSessionFromRedis($sessionId); - $this->assertNotNull($session, "Session {$sessionId} should exist in database"); + $this->assertNotNull($session, "Session {$sessionId} should exist in Redis"); return $session->lastActiveAt; } - private function getSessionFromDatabase(SessionId $id): ?Session + private function getSessionFromRedis(SessionId $id): ?Session { $redis = $this->container->get(Redis::class); @@ -285,11 +288,8 @@ private function getSessionFromDatabase(SessionId $id): ?Session } } - /** - * @return array - */ - private function getSessionDataFromDatabase(SessionId $id): array + private function createSessionId(): SessionId { - return $this->getSessionFromDatabase($id)->data ?? []; + return new SessionId(Random\uuid()); } } diff --git a/tests/Integration/Http/Responses/BackResponseTest.php b/tests/Integration/Http/Responses/BackResponseTest.php index 61acadb517..26e212facd 100644 --- a/tests/Integration/Http/Responses/BackResponseTest.php +++ b/tests/Integration/Http/Responses/BackResponseTest.php @@ -4,11 +4,13 @@ namespace Tests\Tempest\Integration\Http\Responses; +use PHPUnit\Framework\Attributes\Test; use Tempest\Http\GenericRequest; use Tempest\Http\Header; use Tempest\Http\Method; use Tempest\Http\Request; use Tempest\Http\Responses\Back; +use Tempest\Http\Session\PreviousUrl; use Tempest\Http\Status; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -17,50 +19,95 @@ */ final class BackResponseTest extends FrameworkIntegrationTestCase { - public function test_back_response(): void + private PreviousUrl $previousUrl { + get => $this->container->get(PreviousUrl::class); + } + + #[Test] + public function back_response_with_no_previous_url(): void { - $this->bindRequest(); $response = new Back(); $this->assertSame(Status::FOUND, $response->status); $this->assertEquals(new Header('Location', ['/']), $response->headers['Location']); - $this->assertNotSame(Status::OK, $response->status); } - public function test_back_response_with_referer(): void + #[Test] + public function back_response_with_tracked_url(): void { - $this->bindRequest(referer: $referer = '/referer-test'); - - $response = new Back(); + $this->previousUrl->track( + request: new GenericRequest(method: Method::GET, uri: '/previous-page'), + ); - $this->assertEquals(new Header('Location', [$referer]), $response->headers['Location']); + $this->assertEquals( + new Header('Location', ['/previous-page']), + new Back()->headers['Location'], + ); } - public function test_back_response_with_fallback(): void + #[Test] + public function back_response_with_fallback(): void { - $this->bindRequest(); + $this->assertEquals( + new Header('Location', ['/fallback-url']), + new Back('/fallback-url')->headers['Location'], + ); + } - $referer = '/test'; - $response = new Back($referer); + #[Test] + public function back_response_prefers_tracked_url_over_fallback(): void + { + $this->previousUrl->track( + request: new GenericRequest(method: Method::GET, uri: '/tracked-page'), + ); - $this->assertEquals(new Header('Location', [$referer]), $response->headers['Location']); + $this->assertEquals( + new Header('Location', ['/tracked-page']), + new Back('/fallback-url')->headers['Location'], + ); } - public function test_back_response_for_get_request(): void + #[Test] + public function back_response_with_referer_header(): void { - $this->http - ->get('/test-redirect-back-url') - ->assertRedirect('/test-redirect-back-url'); + $this->container->singleton(Request::class, new GenericRequest( + method: Method::GET, + uri: '/current-page', + headers: ['referer' => '/referer-page'], + )); + + $this->assertEquals( + new Header('Location', ['/referer-page']), + new Back()->headers['Location'], + ); } - public function bindRequest(?string $uri = '/', ?string $referer = null): void + #[Test] + public function back_response_prefers_tracked_url_over_referer(): void { - $headers = $referer ? ['referer' => $referer] : []; + $this->previousUrl->track(new GenericRequest( + method: Method::GET, + uri: '/tracked-page', + headers: ['referer' => '/referer-page'], + )); $this->container->singleton(Request::class, new GenericRequest( method: Method::GET, - uri: $uri, - headers: $headers, + uri: '/current-page', + headers: ['referer' => '/referer-page'], )); + + $this->assertEquals( + new Header('Location', ['/tracked-page']), + new Back()->headers['Location'], + ); + } + + #[Test] + public function back_response_for_get_request(): void + { + $this->http + ->get('/test-redirect-back-url') + ->assertRedirect('/test-redirect-back-url'); } } diff --git a/tests/Integration/Http/Responses/GenericResponseTest.php b/tests/Integration/Http/Responses/GenericResponseTest.php index e5bce79a1a..040bbe82bb 100644 --- a/tests/Integration/Http/Responses/GenericResponseTest.php +++ b/tests/Integration/Http/Responses/GenericResponseTest.php @@ -4,6 +4,7 @@ namespace Tests\Tempest\Integration\Http\Responses; +use PHPUnit\Framework\Attributes\Test; use Tempest\Http\Cookie\Cookie; use Tempest\Http\Cookie\CookieManager; use Tempest\Http\Responses\Ok; @@ -16,26 +17,18 @@ */ final class GenericResponseTest extends FrameworkIntegrationTestCase { - public function test_sessions(): void + #[Test] + public function sessions(): void { - $response = new Ok() - ->addSession('test', 'test') - ->addSession('original', 'original'); + new Ok()->flash('success', 'Operation successful'); $session = $this->container->get(Session::class); - $this->assertSame('test', $session->get('test')); - - $response->removeSession('test'); - - $this->assertNull($session->get('test')); - - $response->destroySession(); - - $this->assertNull($session->get('original')); + $this->assertSame('Operation successful', $session->get('success')); } - public function test_cookies(): void + #[Test] + public function cookies(): void { $response = new Ok()->addCookie(new Cookie('test')); @@ -48,7 +41,8 @@ public function test_cookies(): void $this->assertSame(-1, $cookieManager->get('test')->expiresAt); } - public function test_set_status(): void + #[Test] + public function set_status(): void { $response = new Ok()->setStatus(Status::ACCEPTED); diff --git a/tests/Integration/Http/SessionTest.php b/tests/Integration/Http/SessionTest.php new file mode 100644 index 0000000000..3ee186f965 --- /dev/null +++ b/tests/Integration/Http/SessionTest.php @@ -0,0 +1,123 @@ + $this->container->get(Session::class); + } + + #[Test] + public function create_session_from_container(): void + { + $clock = $this->clock(); + + $this->assertInstanceOf(Session::class, $this->session); + $this->assertTrue($this->session->createdAt->equals($clock->now())); + $this->assertTrue($this->session->lastActiveAt->equals($clock->now())); + } + + #[Test] + public function set_and_get(): void + { + $this->session->set('test', 'value'); + $this->assertEquals('value', $this->session->get('test')); + + $this->session->set('nested', ['key' => 'value']); + $this->assertEquals(['key' => 'value'], $this->session->get('nested')); + } + + #[Test] + public function get_with_default(): void + { + $this->assertEquals('default', $this->session->get('nonexistent', 'default')); + $this->assertNull($this->session->get('nonexistent')); + } + + #[Test] + public function remove(): void + { + $this->session->set('test', 'value'); + $this->assertEquals('value', $this->session->get('test')); + + $this->session->remove('test'); + $this->assertNull($this->session->get('test')); + } + + #[Test] + public function all(): void + { + $this->session->set('key1', 'value1'); + $this->session->set('key2', 'value2'); + + $data = $this->session->all(); + + $this->assertArrayHasKey('key1', $data); + $this->assertArrayHasKey('key2', $data); + $this->assertEquals('value1', $data['key1']); + $this->assertEquals('value2', $data['key2']); + } + + #[Test] + public function flash(): void + { + $this->session->flash('message', 'success'); + $this->assertEquals('success', $this->session->get('message')); + + $this->session->cleanup(); + $this->assertNull($this->session->get('message')); + } + + #[Test] + public function reflash(): void + { + $this->session->flash('test', 'value'); + $this->session->flash('test2', ['key' => 'value']); + + $this->assertEquals('value', $this->session->get('test')); + + $this->session->reflash(); + $this->session->cleanup(); + + $this->assertEquals('value', $this->session->get('test')); + $this->assertEquals(['key' => 'value'], $this->session->get('test2')); + } + + #[Test] + public function consume(): void + { + $this->session->set('token', 'abc123'); + + $this->assertEquals('abc123', $this->session->consume('token')); + $this->assertNull($this->session->get('token')); + } + + #[Test] + public function consume_with_default(): void + { + $this->assertEquals('default', $this->session->consume('nonexistent', 'default')); + } + + #[Test] + public function clear(): void + { + $this->session->set('key1', 'value1'); + $this->session->set('key2', 'value2'); + + $this->assertCount(2, $this->session->all()); + + $this->session->clear(); + + $this->assertEmpty($this->session->all()); + } +} diff --git a/tests/Integration/Http/TrackPreviousUrlMiddlewareTest.php b/tests/Integration/Http/TrackPreviousUrlMiddlewareTest.php new file mode 100644 index 0000000000..a51c485807 --- /dev/null +++ b/tests/Integration/Http/TrackPreviousUrlMiddlewareTest.php @@ -0,0 +1,111 @@ + $this->container->get(PreviousUrl::class); + } + + private TrackPreviousUrlMiddleware $middleware { + get => new TrackPreviousUrlMiddleware($this->previousUrl); + } + + #[Test] + public function middleware_tracks_request_url(): void + { + $this->middleware->__invoke( + request: new GenericRequest(method: Method::GET, uri: '/dashboard'), + next: new HttpMiddlewareCallable(fn () => new GenericResponse(Status::OK)), + ); + + $this->assertEquals('/dashboard', $this->previousUrl->get()); + } + + #[Test] + public function middleware_calls_next_handler(): void + { + $expected = new GenericResponse(Status::OK, body: 'Test response'); + + $response = $this->middleware->__invoke( + request: new GenericRequest(method: Method::GET, uri: '/test'), + next: new HttpMiddlewareCallable(fn () => $expected), + ); + + $this->assertSame($expected, $response); + } + + #[Test] + public function middleware_does_not_track_post_requests(): void + { + $this->middleware->__invoke( + request: new GenericRequest(method: Method::POST, uri: '/form-submit'), + next: new HttpMiddlewareCallable(fn () => new GenericResponse(Status::OK)), + ); + + $this->assertEquals('/', $this->previousUrl->get()); + } + + #[Test] + public function middleware_tracks_multiple_requests_in_sequence(): void + { + $next = new HttpMiddlewareCallable(fn () => new GenericResponse(Status::OK)); + + $this->middleware->__invoke( + request: new GenericRequest(method: Method::GET, uri: '/page1'), + next: $next, + ); + + $this->assertEquals('/page1', $this->previousUrl->get()); + + $this->middleware->__invoke( + request: new GenericRequest(method: Method::GET, uri: '/page2'), + next: $next, + ); + + $this->assertEquals('/page2', $this->previousUrl->get()); + + $this->middleware->__invoke( + request: new GenericRequest(method: Method::GET, uri: '/page3'), + next: $next, + ); + + $this->assertEquals('/page3', $this->previousUrl->get()); + } + + #[Test] + public function middleware_ignores_ajax_requests(): void + { + $this->middleware->__invoke( + request: new GenericRequest(method: Method::GET, uri: '/dashboard'), + next: new HttpMiddlewareCallable(fn () => new GenericResponse(Status::OK)), + ); + + $this->middleware->__invoke( + request: new GenericRequest( + method: Method::GET, + uri: '/api/data', + headers: ['X-Requested-With' => 'XMLHttpRequest'], + ), + next: new HttpMiddlewareCallable(fn () => new GenericResponse(Status::OK)), + ); + + $this->assertEquals('/dashboard', $this->previousUrl->get()); + } +} diff --git a/tests/Integration/Http/ValidationResponseTest.php b/tests/Integration/Http/ValidationResponseTest.php index de93194b6f..918ed888a2 100644 --- a/tests/Integration/Http/ValidationResponseTest.php +++ b/tests/Integration/Http/ValidationResponseTest.php @@ -6,7 +6,7 @@ use Tempest\Database\Migrations\CreateMigrationsTable; use Tempest\Http\ContentType; -use Tempest\Http\Session\Session; +use Tempest\Http\Session\FormSession; use Tests\Tempest\Fixtures\Controllers\ValidationController; use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; use Tests\Tempest\Fixtures\Migrations\CreateBookTable; @@ -49,9 +49,7 @@ public function test_original_values(): void ) ->assertRedirect(uri([ValidationController::class, 'store'])) ->assertHasValidationError('number') - ->assertHasSession(Session::ORIGINAL_VALUES, function (Session $_session, array $data) use ($values): void { - $this->assertEquals($values, $data); - }); + ->assertHasFormOriginalValues($values); } public function test_update_book(): void @@ -116,9 +114,9 @@ public function test_sensitive_fields_are_excluded_from_original_values(): void headers: ['referer' => '/test-sensitive-validation'], ) ->assertHasValidationError('not_sensitive_param') - ->assertHasSession(Session::ORIGINAL_VALUES, function (Session $_session, array $data): void { - $this->assertArrayNotHasKey('sensitive_param', $data); - $this->assertArrayHasKey('not_sensitive_param', $data); + ->assertHasForm(function (FormSession $form): void { + $this->assertNull($form->getOriginalValueFor('sensitive_param')); + $this->assertNotNull($form->getOriginalValueFor('not_sensitive_param')); }); } } diff --git a/tests/Integration/Route/ClientTest.php b/tests/Integration/Route/ClientTest.php index 7f281bab25..e5e93dbf18 100644 --- a/tests/Integration/Route/ClientTest.php +++ b/tests/Integration/Route/ClientTest.php @@ -53,6 +53,8 @@ public function test_form_post_request(): void ->withHeader('Referer', 'http://localhost:8088/request-test/form') ->withHeader('Accept', 'application/json') ->withHeader('Content-Type', 'application/x-www-form-urlencoded') + ->withHeader('Sec-Fetch-Site', 'same-origin') + ->withHeader('Sec-Fetch-Mode', 'cors') ->withBody(new StreamFactory()->createStream('name=a a&b.name=b')); try { @@ -72,6 +74,8 @@ public function test_json_post_request(): void ->createRequest('POST', new Uri('http://localhost:8088/request-test/form')) ->withHeader('Accept', 'application/json') ->withHeader('Content-Type', 'application/json') + ->withHeader('Sec-Fetch-Site', 'same-origin') + ->withHeader('Sec-Fetch-Mode', 'cors') ->withBody(new StreamFactory()->createStream('{"name": "a a", "b": {"name": "b"}}')); try { diff --git a/tests/Integration/Route/RequestTest.php b/tests/Integration/Route/RequestTest.php index 4c198d670f..5648c567c2 100644 --- a/tests/Integration/Route/RequestTest.php +++ b/tests/Integration/Route/RequestTest.php @@ -85,7 +85,7 @@ public function test_from_factory(): void $this->assertEquals(Method::POST->value, $request->getMethod()); $this->assertEquals('/test', $request->getUri()->getPath()); $this->assertEquals(['test' => 'test'], $request->getParsedBody()); - $this->assertEquals(['x-test' => ['test']], $request->getHeaders()); + $this->assertArrayIsEqualToArrayIgnoringListOfKeys(['x-test' => ['test']], $request->getHeaders(), ['sec-fetch-site', 'sec-fetch-mode']); $this->assertCount(1, $request->getCookieParams()); $this->assertArrayHasKey('test', $request->getCookieParams()); $this->assertSame('test', $this->container->get(Encrypter::class)->decrypt($request->getCookieParams()['test'])); diff --git a/tests/Integration/Route/RouterTest.php b/tests/Integration/Route/RouterTest.php index fe97fd00fd..7e31d684ed 100644 --- a/tests/Integration/Route/RouterTest.php +++ b/tests/Integration/Route/RouterTest.php @@ -8,12 +8,14 @@ use Laminas\Diactoros\Stream; use Laminas\Diactoros\Uri; use Tempest\Database\Migrations\CreateMigrationsTable; +use Tempest\Http\ContentType; use Tempest\Http\Responses\Ok; -use Tempest\Http\Session\VerifyCsrfMiddleware; use Tempest\Http\Status; use Tempest\Router\GenericRouter; use Tempest\Router\RouteConfig; use Tempest\Router\Router; +use Tempest\Router\SecFetchMode; +use Tempest\Router\SecFetchSite; use Tests\Tempest\Fixtures\Controllers\TestGlobalMiddleware; use Tests\Tempest\Fixtures\Controllers\TestMiddleware; use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; @@ -162,7 +164,9 @@ public function test_json_request(): void method: 'POST', body: new Stream(fopen(__DIR__ . '/request.json', 'r')), headers: [ - 'Content-Type' => 'application/json', + 'Content-Type' => ContentType::JSON->value, + 'Sec-Fetch-Site' => SecFetchSite::SAME_ORIGIN->value, + 'Sec-Fetch-Mode' => SecFetchMode::CORS->value, ], )); @@ -239,8 +243,7 @@ public function test_stateless_decorator(): void $this->http ->get('/stateless') ->assertOk() - ->assertDoesNotHaveCookie('tempest_session_id') - ->assertDoesNotHaveCookie(VerifyCsrfMiddleware::CSRF_COOKIE_KEY); + ->assertDoesNotHaveCookie('tempest_session_id'); } public function test_prefix_decorator(): void @@ -263,7 +266,7 @@ public function test_without_middleware_decorator(): void $this->http ->get('/without-decorated-middleware') ->assertOk() - ->assertDoesNotHaveCookie(VerifyCsrfMiddleware::CSRF_COOKIE_KEY); + ->assertDoesNotHaveHeader('set-cookie'); } public function test_optional_parameter_with_required_parameter(): void diff --git a/tests/Integration/Router/PreventCrossSiteRequestsMiddlewareTest.php b/tests/Integration/Router/PreventCrossSiteRequestsMiddlewareTest.php new file mode 100644 index 0000000000..3f568d1787 --- /dev/null +++ b/tests/Integration/Router/PreventCrossSiteRequestsMiddlewareTest.php @@ -0,0 +1,187 @@ +container->get(RouteConfig::class) + ->middleware->add(PreventCrossSiteRequestsMiddleware::class); + } + + #[Test] + public function safe_methods_are_always_allowed(): void + { + $this->http->get('/test')->assertOk(); + $this->http->head('/test')->assertOk(); + $this->http->options('/test')->assertOk(); + } + + #[Test] + public function post_with_same_origin_is_allowed(): void + { + $this->http + ->post('/test', headers: [ + 'sec-fetch-site' => SecFetchSite::SAME_ORIGIN, + 'sec-fetch-mode' => SecFetchMode::CORS, + ]) + ->assertOk(); + } + + #[Test] + public function post_with_same_site_is_allowed(): void + { + $this->http + ->post('/test', headers: [ + 'sec-fetch-site' => SecFetchSite::SAME_SITE, + 'sec-fetch-mode' => SecFetchMode::CORS, + ]) + ->assertOk(); + } + + #[Test] + public function post_with_none_site_is_allowed(): void + { + $this->http + ->post('/test', headers: [ + 'sec-fetch-site' => SecFetchSite::NONE, + 'sec-fetch-mode' => SecFetchMode::NAVIGATE, + ]) + ->assertOk(); + } + + #[Test] + public function post_with_cross_site_navigation_is_allowed(): void + { + $this->http + ->post('/test', headers: [ + 'sec-fetch-site' => SecFetchSite::CROSS_SITE, + 'sec-fetch-mode' => SecFetchMode::NAVIGATE, + ]) + ->assertOk(); + } + + #[Test] + public function post_with_cross_site_cors_is_blocked(): void + { + $this->http + ->post('/test', headers: [ + 'sec-fetch-site' => SecFetchSite::CROSS_SITE, + 'sec-fetch-mode' => SecFetchMode::CORS, + ]) + ->assertForbidden(); + } + + #[Test] + public function put_with_cross_site_cors_is_blocked(): void + { + $this->http + ->put('/test', headers: [ + 'sec-fetch-site' => SecFetchSite::CROSS_SITE, + 'sec-fetch-mode' => SecFetchMode::CORS, + ]) + ->assertForbidden(); + } + + #[Test] + public function patch_with_cross_site_cors_is_blocked(): void + { + $this->http + ->patch('/test', headers: [ + 'sec-fetch-site' => SecFetchSite::CROSS_SITE, + 'sec-fetch-mode' => SecFetchMode::CORS, + ]) + ->assertForbidden(); + } + + #[Test] + public function delete_with_cross_site_cors_is_blocked(): void + { + $this->http + ->delete('/test', headers: [ + 'sec-fetch-site' => SecFetchSite::CROSS_SITE, + 'sec-fetch-mode' => SecFetchMode::CORS, + ]) + ->assertForbidden(); + } + + #[Test] + public function post_without_sec_fetch_headers_is_blocked(): void + { + $this->http + ->withoutSecFetchHeaders() + ->post('/test') + ->assertForbidden(); + } + + #[Test] + public function post_with_cross_site_no_cors_is_blocked(): void + { + $this->http + ->post('/test', headers: [ + 'sec-fetch-site' => SecFetchSite::CROSS_SITE, + 'sec-fetch-mode' => SecFetchMode::NO_CORS, + ]) + ->assertForbidden(); + } + + #[Test] + public function post_with_cross_site_websocket_is_blocked(): void + { + $this->http + ->post('/test', headers: [ + 'sec-fetch-site' => SecFetchSite::CROSS_SITE, + 'sec-fetch-mode' => SecFetchMode::WEBSOCKET, + ]) + ->assertForbidden(); + } + + #[Test] + public function delete_with_same_origin_is_allowed(): void + { + $this->http + ->delete('/test', headers: [ + 'sec-fetch-site' => SecFetchSite::SAME_ORIGIN, + 'sec-fetch-mode' => SecFetchMode::CORS, + ]) + ->assertOk(); + } + + #[Test] + public function put_with_same_site_is_allowed(): void + { + $this->http + ->put('/test', headers: [ + 'sec-fetch-site' => SecFetchSite::SAME_SITE, + 'sec-fetch-mode' => SecFetchMode::CORS, + ]) + ->assertOk(); + } + + #[Test] + public function patch_with_none_site_is_allowed(): void + { + $this->http + ->patch('/test', headers: [ + 'sec-fetch-site' => SecFetchSite::NONE, + 'sec-fetch-mode' => SecFetchMode::NAVIGATE, + ]) + ->assertOk(); + } +} diff --git a/tests/Integration/View/Components/FormComponentTest.php b/tests/Integration/View/Components/FormComponentTest.php index 07f237abe0..8c864f8db3 100644 --- a/tests/Integration/View/Components/FormComponentTest.php +++ b/tests/Integration/View/Components/FormComponentTest.php @@ -13,7 +13,6 @@ public function test_form(): void $this->assertStringContainsString('assertStringContainsString('method="POST"', $html); - $this->assertStringContainsString('#csrf_token', $html); } public function test_form_with_body(): void diff --git a/tests/Integration/View/Components/InputComponentTest.php b/tests/Integration/View/Components/InputComponentTest.php index 3f037198cb..94dcdaf2ef 100644 --- a/tests/Integration/View/Components/InputComponentTest.php +++ b/tests/Integration/View/Components/InputComponentTest.php @@ -2,7 +2,8 @@ namespace Tests\Tempest\Integration\View\Components; -use Tempest\Http\Session\Session; +use Tempest\Http\Session\FormSession; +use Tempest\Validation\FailingRule; use Tempest\Validation\Rules\HasLength; use Tempest\Validation\Rules\IsInteger; use Tempest\Validation\Rules\IsString; @@ -43,7 +44,7 @@ public function test_with_type(): void public function test_input_original(): void { - $this->get(Session::class)->set(Session::ORIGINAL_VALUES, [ + $this->get(FormSession::class)->setOriginalValues([ 'name' => 'original', 'other' => 'other', ]); @@ -63,7 +64,7 @@ public function test_textarea(): void public function test_textarea_original(): void { - $this->get(Session::class)->set(Session::ORIGINAL_VALUES, [ + $this->get(FormSession::class)->setOriginalValues([ 'name' => 'original', 'other' => 'other', ]); @@ -77,15 +78,15 @@ public function test_error_message(): void { $failingRules = [ 'name' => [ - new IsString(), - new HasLength(min: 5), + new FailingRule(new IsString()), + new FailingRule(new HasLength(min: 5)), ], 'other' => [ - new IsInteger(), + new FailingRule(new IsInteger()), ], ]; - $this->get(Session::class)->set(Session::VALIDATION_ERRORS, $failingRules); + $this->get(FormSession::class)->setErrors($failingRules); $html = $this->render(''); diff --git a/tests/Integration/View/ViewComponentTest.php b/tests/Integration/View/ViewComponentTest.php index c02b8b179c..cf3291e854 100644 --- a/tests/Integration/View/ViewComponentTest.php +++ b/tests/Integration/View/ViewComponentTest.php @@ -7,7 +7,8 @@ use Generator; use PHPUnit\Framework\Attributes\DataProvider; use Tempest\Core\Environment; -use Tempest\Http\Session\Session; +use Tempest\Http\Session\FormSession; +use Tempest\Validation\FailingRule; use Tempest\Validation\Rules\IsAlphaNumeric; use Tempest\Validation\Rules\IsBetween; use Tempest\Validation\Validator; @@ -214,18 +215,15 @@ public function test_component_with_anther_component_included_with_slot(): void public function test_view_component_with_injected_view(): void { - $between = new IsBetween(min: 1, max: 10); - $alphaNumeric = new IsAlphaNumeric(); + $between = new FailingRule(new IsBetween(min: 1, max: 10)); + $alphaNumeric = new FailingRule(new IsAlphaNumeric()); - $session = $this->container->get(Session::class); - - $session->flash( - Session::VALIDATION_ERRORS, + $formSession = $this->container->get(FormSession::class); + $formSession->setErrors( ['name' => [$between, $alphaNumeric]], ); - $session->flash( - Session::ORIGINAL_VALUES, + $formSession->setOriginalValues( ['name' => 'original name'], );