Files
spiral-testing/src/Http/FakeHttp.php
T
dualfrozandroxblnfk 0171b86dd8 Keep configured auth transports when faking an actor (#91)
Assisted-By: Claude Fable 5.1
Co-authored-by: roxblnfk <roxblnfk@ya.ru>
2026-09-21 18:30:28 +04:00

490 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace Spiral\Testing\Http;
use Nyholm\Psr7\ServerRequest;
use Nyholm\Psr7\Stream;
use Psr\Container\ContainerInterface;
use Psr\Container\NotFoundExceptionInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UploadedFileInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Spiral\Auth\ActorProviderInterface;
use Spiral\Auth\TokenStorageInterface;
use Spiral\Auth\Transport\HeaderTransport;
use Spiral\Auth\TransportRegistry;
use Spiral\Boot\FinalizerInterface;
use Spiral\Core\Attribute\Proxy;
use Spiral\Core\FactoryInterface;
use Spiral\Http\Http;
use Spiral\Http\LazyPipeline;
use Spiral\Session\SessionInterface;
use Spiral\Testing\Auth\FakeActorProvider;
use Spiral\Testing\Session\FakeSession;
class FakeHttp
{
private const AUTH_TOKEN_HEADER_KEY = 'X-Test-Token';
private array $defaultServerVariables = [];
private array $defaultHeaders = [];
private array $defaultCookies = [];
private ?object $actor = null;
private ?SessionInterface $session = null;
private array $bindings = [];
/** @var array<non-empty-string> */
private array $addedMiddleware = [];
/**
* @param \Closure(\Closure $closure, array $bindings): mixed $scope Scope runner
*/
public function __construct(
#[Proxy] private readonly ContainerInterface $container,
private readonly FileFactory $fileFactory,
private readonly \Closure $scope,
) {}
public function withActor(object $actor): self
{
$this->actor = $actor;
return $this;
}
public function withServerVariables(array $variables): self
{
$this->defaultServerVariables = $variables;
return $this;
}
public function flushServerVariables(): self
{
$this->defaultServerVariables = [];
return $this;
}
public function withHeaders(array $headers): self
{
$this->defaultHeaders = $headers;
return $this;
}
public function withHeader(string $key, $value): self
{
$this->defaultHeaders[$key] = $value;
return $this;
}
public function withAuthorizationToken(string $token, string $type = 'Bearer'): self
{
return $this->withHeader('Authorization', $type . ' ' . $token);
}
public function flushHeaders(): self
{
$this->defaultHeaders = [];
return $this;
}
public function withCookies(array $cookies): self
{
$this->defaultCookies = $cookies;
return $this;
}
public function withCookie(string $name, string $value): self
{
$this->defaultCookies[$name] = $value;
return $this;
}
public function flushCookies(): self
{
$this->defaultCookies = [];
return $this;
}
public function withSession(
array $data,
string $clientSignature = 'fake-session',
int $lifetime = 3600,
?string $id = null,
): self {
$this->session = new FakeSession($data, $clientSignature, $lifetime, $id);
return $this;
}
public function flushSession(): self
{
$this->session = null;
return $this;
}
/**
* Prepend middleware to the pipeline.
*
* @param non-empty-string ...$middleware
*/
public function withMiddleware(string ...$middleware): self
{
foreach ($middleware as $name) {
\array_unshift($this->addedMiddleware, $name);
unset($this->bindings[$name]);
}
return $this;
}
/**
* Remove middleware from the pipeline.
*
* @param non-empty-string ...$middleware
*/
public function withoutMiddleware(string ...$middleware): self
{
foreach ($middleware as $name) {
// Remove middleware from added middleware list
$this->addedMiddleware = \array_filter($this->addedMiddleware, static fn($m): bool => $m !== $name);
$this->bindings[$name] = new class implements MiddlewareInterface {
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler,
): ResponseInterface {
return $handler->handle($request);
}
};
}
return $this;
}
public function getFileFactory(): FileFactory
{
return $this->fileFactory;
}
/**
* @note The HTTP instance will not contain all configured things like middleware, etc.
* It's just a simple HTTP instance from the Container.
*/
public function getHttp(): Http
{
return $this->container->get(Http::class);
}
public function get(string $uri, array $query = [], array $headers = [], array $cookies = []): TestResponse
{
return $this->handleRequest(
$this->createRequest($uri, 'GET', $query, $headers, $cookies),
);
}
public function getJson(
string $uri,
array $query = [],
array $headers = [],
array $cookies = [],
): TestResponse {
return $this->handleRequest(
$this->createJsonRequest($uri, 'GET', $query, $headers, $cookies),
);
}
public function getWithAttributes(string $uri, array $attributes, array $headers = []): TestResponse
{
$r = $this->createRequest($uri, 'GET', [], $headers, []);
foreach ($attributes as $k => $v) {
$r = $r->withAttribute($k, $v);
}
return $this->handleRequest($r);
}
/**
* @param array|object|StreamInterface $data
*/
public function post(
string $uri,
$data = [],
array $headers = [],
array $cookies = [],
array $files = [],
): TestResponse {
$this->validateRequestData($data);
$request = $this->createRequest($uri, 'POST', [], $headers, $cookies, $files);
return $this->handleRequest(
$data instanceof StreamInterface
? $request->withBody($data)
: $request->withParsedBody($data),
);
}
/**
* @param array|StreamInterface $data
*/
public function postJson(
string $uri,
$data = [],
array $headers = [],
array $cookies = [],
array $files = [],
): TestResponse {
return $this->handleRequest(
$this->createJsonRequest($uri, 'POST', $data, $headers, $cookies, $files),
);
}
/**
* @param array|object|StreamInterface $data
*/
public function patch(
string $uri,
$data = [],
array $headers = [],
array $cookies = [],
array $files = [],
): TestResponse {
$this->validateRequestData($data);
$request = $this->createRequest($uri, 'PATCH', [], $headers, $cookies, $files);
return $this->handleRequest(
$data instanceof StreamInterface
? $request->withBody($data)
: $request->withParsedBody($data),
);
}
/**
* @param array|StreamInterface $data
*/
public function patchJson(
string $uri,
$data = [],
array $headers = [],
array $cookies = [],
array $files = [],
): TestResponse {
return $this->handleRequest(
$this->createJsonRequest($uri, 'PATCH', $data, $headers, $cookies, $files),
);
}
public function put(
string $uri,
$data = [],
array $headers = [],
array $cookies = [],
array $files = [],
): TestResponse {
$this->validateRequestData($data);
$request = $this->createRequest($uri, 'PUT', [], $headers, $cookies, $files);
return $this->handleRequest(
$data instanceof StreamInterface
? $request->withBody($data)
: $request->withParsedBody($data),
);
}
public function putJson(
string $uri,
$data = [],
array $headers = [],
array $cookies = [],
array $files = [],
): TestResponse {
return $this->handleRequest(
$this->createJsonRequest($uri, 'PUT', $data, $headers, $cookies, $files),
);
}
public function delete(
string $uri,
$data = [],
array $headers = [],
array $cookies = [],
array $files = [],
): TestResponse {
$this->validateRequestData($data);
$request = $this->createRequest($uri, 'DELETE', [], $headers, $cookies, $files);
return $this->handleRequest(
$data instanceof StreamInterface
? $request->withBody($data)
: $request->withParsedBody($data),
);
}
public function deleteJson(
string $uri,
$data = [],
array $headers = [],
array $cookies = [],
array $files = [],
): TestResponse {
return $this->handleRequest(
$this->createJsonRequest($uri, 'DELETE', $data, $headers, $cookies, $files),
);
}
public function createJsonRequest(
string $uri,
string $method,
$data,
array $headers,
array $cookies,
array $files = [],
): ServerRequest {
if (!\is_array($data) && !$data instanceof StreamInterface) {
throw new \InvalidArgumentException(
\sprintf('$data should be array or instance of `%s` interface.', StreamInterface::class),
);
}
if (!$data instanceof StreamInterface) {
$data = Stream::create(\json_encode($data));
}
$headers = \array_merge([
'Content-Length' => $data->getSize(),
'Content-Type' => 'application/json',
'Accept' => 'application/json',
], $headers);
return $this->createRequest($uri, $method, [], $headers, $cookies, $files)
->withBody($data);
}
/**
* @param array<UploadedFileInterface> $files
*/
public function createRequest(
string $uri,
string $method,
array $query,
array $headers,
array $cookies,
array $files = [],
): ServerRequest {
$cookies = \array_merge($this->defaultCookies, $cookies);
$headers = \array_merge($this->defaultHeaders, $headers);
$request = new ServerRequest(
$method,
$uri,
$headers,
'php://input',
'1.1',
$this->defaultServerVariables,
);
if ($request->getUri()->getQuery() !== '') {
$uriQuery = [];
\parse_str($request->getUri()->getQuery(), $uriQuery);
$query = \array_merge($uriQuery, $query);
} else {
$request = $request->withUri($request->getUri()->withQuery(\http_build_query($query)));
}
return $request
->withCookieParams($cookies)
->withQueryParams($query)
->withUploadedFiles($files);
}
public function handleRequest(ServerRequestInterface $request, array $bindings = []): TestResponse
{
$bindings = \array_merge($this->bindings, $bindings);
if ($this->actor) {
$request = $request->withHeader(static::AUTH_TOKEN_HEADER_KEY, \spl_object_hash($this->actor));
$bindings[ActorProviderInterface::class] = new FakeActorProvider($this->actor);
$bindings[TokenStorageInterface::class] = new FakeTokenStorage();
$bindings[TransportRegistry::class] = $this->createFakeTransportRegistry();
}
if ($this->session) {
$bindings[SessionInterface::class] = $this->session;
}
$handler = function () use ($request): ResponseInterface {
if ($this->addedMiddleware === []) {
return $this->getHttp()->handle($request);
}
// Add middleware to the pipeline
/** @var FactoryInterface $factory */
$factory = $this->container->get(FactoryInterface::class);
$pipeline = $factory->make(LazyPipeline::class);
return $pipeline->withMiddleware(...$this->addedMiddleware)
->withHandler($this->getHttp())
->handle($request);
};
try {
return new TestResponse(($this->scope)($handler, $bindings));
} finally {
try {
$this->container->get(FinalizerInterface::class)->finalize(false);
} catch (\Throwable) {
// Ignore exceptions when finalizer is out of scope
}
}
}
protected function validateRequestData($data): void
{
if (!\is_array($data) && !\is_object($data)) {
throw new \InvalidArgumentException('$data should be an array or an object.');
}
}
/**
* The fake token travels in the {@see self::AUTH_TOKEN_HEADER_KEY} header, so every transport the
* application has configured is replaced by one that reads it. The configured names are kept because
* {@see \Spiral\Auth\Middleware\AuthTransportMiddleware} resolves a transport by name.
*/
private function createFakeTransportRegistry(): TransportRegistry
{
$registry = new TransportRegistry();
$transport = new HeaderTransport(static::AUTH_TOKEN_HEADER_KEY);
try {
/** @var TransportRegistry $configured */
$configured = $this->container->get(TransportRegistry::class);
foreach (\array_keys($configured->getTransports()) as $name) {
$registry->setTransport((string) $name, $transport);
}
} catch (NotFoundExceptionInterface) {
// Nothing to copy the names from; the fake transport is all there is.
}
$registry->setTransport('testing', $transport);
$registry->setDefaultTransport('testing');
return $registry;
}
}