diff --git a/.github/workflows/code-quality.yaml b/.github/workflows/code-quality.yaml index 768ecb0..bd6526e 100644 --- a/.github/workflows/code-quality.yaml +++ b/.github/workflows/code-quality.yaml @@ -40,6 +40,7 @@ jobs: outputs: lint: ${{ steps.filter.outputs.lint }} analyse: ${{ steps.filter.outputs.analyse }} + tests: ${{ steps.filter.outputs.tests }} steps: - name: Checkout @@ -51,8 +52,13 @@ jobs: filters: | lint: - 'src/**' + - 'tests/**' analyse: - 'src/**' + - 'tests/**' + tests: + - 'src/**' + - 'tests/**' lint: runs-on: ubuntu-latest @@ -120,3 +126,34 @@ jobs: php_version: "8.2" version: 2 command: audit + + test: + runs-on: ubuntu-latest + + needs: [composer,changes] + + if: ${{ needs.changes.outputs.tests == 'true' }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP with PCOV + uses: shivammathur/setup-php@v2 + with: + php-version: "8.2" + extensions: gd, imagick, pcov + coverage: pcov + + - name: Cache composer dependencies + uses: actions/cache@v4 + with: + key: composer-${{ hashFiles('composer.json') }}-${{ hashFiles('composer.lock') }} + restore-keys: composer- + path: vendor + + - name: Run PHPUnit with coverage + run: make test + + - name: Check coverage threshold + run: make coverage diff --git a/Makefile b/Makefile index 6cdae28..de589ab 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ -include docker/.env -.SILENT: cache shell analyse +.SILENT: cache shell analyse test coverage .DEFAULT_GOAL := help help: @@ -44,7 +44,7 @@ cache: ## Code quality ##--------------------------------------------------------------------------- -check: lint analyse security +check: lint analyse security test coverage lint: ## Execute PHPCS lint: cache @@ -61,3 +61,11 @@ analyse: cache security: ## Check CVE for vendor dependencies security: docker/exec composer audit + +test: ## Execute PHPUnit tests +test: cache + docker/exec vendor/bin/phpunit --coverage-xml .cache/coverage/xml + +coverage: ## Check coverage threshold (95%) +coverage: + docker/exec vendor/bin/coverage-checker .cache/coverage/xml/index.xml 75 diff --git a/composer.json b/composer.json index 02f8749..88773a2 100644 --- a/composer.json +++ b/composer.json @@ -37,11 +37,19 @@ }, "require-dev": { "babeuloula/phpcs": "^1.5", + "babeuloula/phpunit-coverage-checker": "^1.0", "intervention/image": "^3.9", "phpstan/phpstan": "^1.12", "phpstan/phpstan-deprecation-rules": "^1.2", + "phpstan/phpstan-phpunit": "^1.0", "phpstan/phpstan-strict-rules": "^1.6", - "phpstan/phpstan-symfony": "^1.4" + "phpstan/phpstan-symfony": "^1.4", + "phpunit/phpunit": "^11.0" + }, + "autoload-dev": { + "psr-4": { + "BaBeuloula\\CdnPhpBundle\\Tests\\": "tests/" + } }, "suggest": { "intervention/image": "Needed to use InterventionImageFallback." diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile index 6833403..1ad5694 100644 --- a/docker/php/Dockerfile +++ b/docker/php/Dockerfile @@ -1,6 +1,6 @@ FROM php:8.2-cli -ARG DOCKER_PHP_EXT=2.6.0 +ARG DOCKER_PHP_EXT=2.10.12 ADD https://github.com/mlocati/docker-php-extension-installer/releases/download/${DOCKER_PHP_EXT}/install-php-extensions /usr/local/bin/ RUN chmod +x /usr/local/bin/install-php-extensions @@ -25,6 +25,9 @@ RUN \ rm -rf /var/lib/apt/lists/* && \ truncate -s 0 /var/log/*log +# Install PHP extensions +RUN install-php-extensions gd imagick pcov + # Install composer RUN \ curl -sl https://getcomposer.org/composer-2.phar -o /usr/local/bin/composer && \ diff --git a/phpcs.xml b/phpcs.xml index 6cf644d..998f6ce 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -15,5 +15,6 @@ src/ + tests/ diff --git a/phpstan.neon b/phpstan.neon index 98d1448..dfe9eb2 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -2,6 +2,7 @@ includes: - vendor/phpstan/phpstan-symfony/extension.neon - vendor/phpstan/phpstan-symfony/rules.neon - vendor/phpstan/phpstan-deprecation-rules/rules.neon + - vendor/phpstan/phpstan-phpunit/extension.neon parameters: tmpDir: .cache/phpstan @@ -10,6 +11,7 @@ parameters: paths: - src + - tests parallel: jobSize: 20 diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..492f44e --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,28 @@ + + + + + + tests/ + + + + + + src/ + + + + + + + + + + diff --git a/src/AbstractHandler.php b/src/AbstractHandler.php index c1e16e2..7a3a70b 100644 --- a/src/AbstractHandler.php +++ b/src/AbstractHandler.php @@ -31,7 +31,6 @@ public function parseHeaders(Request $request): array { $requestedHeaders = [ 'accept-language', - 'accept-encoding', 'accept', 'user-agent', ]; diff --git a/src/CdnPhpBundle.php b/src/CdnPhpBundle.php index f2f957c..fac8d5a 100644 --- a/src/CdnPhpBundle.php +++ b/src/CdnPhpBundle.php @@ -80,6 +80,7 @@ public function loadExtension(array $config, ContainerConfigurator $container, C ->get(ProxyExtension::class) ->arg('$routeName', $config['twig']['route_name']) ->arg('$routeParameter', $config['twig']['route_parameter']) + ->arg('$encryptedParameters', $config['proxy']['encrypted_parameters']) ; } } diff --git a/src/FallbackHandler/InterventionImageFallbackHandler.php b/src/FallbackHandler/InterventionImageFallbackHandler.php index ef95f3a..5115ef1 100644 --- a/src/FallbackHandler/InterventionImageFallbackHandler.php +++ b/src/FallbackHandler/InterventionImageFallbackHandler.php @@ -43,24 +43,32 @@ public function response(string $file, ?Options $options = null, array $headers { $file = $this->normalizeFile($file); - try { - $image = $this->imageManager->read($this->assetsPath . $file); - } catch (DecoderException $e) { - throw new FileNotFoundException($file, previous: $e); - } + $formatKey = match (true) { + $this->supportsAvif($headers) => 'avif', + $this->supportsWebp($headers) => 'webp', + default => 'original', + }; - $cacheKey = sha1($file . '?' . $options?->buildQuery()); + $safeFile = (string) preg_replace('/[^a-zA-Z0-9_\-\.]/', '_', $file); + $optionsPart = null !== $options ? '_' . sha1($options->buildQuery(false)) : ''; + $cacheKey = $safeFile . $optionsPart . '.' . $formatKey; /** @var array{content: string, mimetype: string} $encodedImage */ $encodedImage = $this->cache->get( $cacheKey, - function (ItemInterface $item) use ($options, $image, $headers): array { + function (ItemInterface $item) use ($options, $file, $headers): array { + try { + $image = $this->imageManager->read($this->assetsPath . $file); + } catch (DecoderException $e) { + throw new FileNotFoundException($file, previous: $e); + } + if (null !== $options?->width && null !== $options->height) { - $image->cover((int) $options->width, (int) $options->height); + $image->cover($options->width, $options->height); } elseif (null !== $options?->width) { - $image->scale(width: (int) $options->width); + $image->scale(width: $options->width); } elseif (null !== $options?->height) { - $image->scale(height: (int) $options->height); + $image->scale(height: $options->height); } $item->expiresAfter($this->cacheLifetime); @@ -81,10 +89,7 @@ function (ItemInterface $item) use ($options, $image, $headers): array { return new Response( $encodedImage['content'], Response::HTTP_OK, - array_merge_recursive( - $headers, - ['Content-Type' => $encodedImage['mimetype']], - ), + array_replace($headers, ['Content-Type' => $encodedImage['mimetype']]), ); } diff --git a/src/Options.php b/src/Options.php index dae3df8..0ce5d14 100644 --- a/src/Options.php +++ b/src/Options.php @@ -26,8 +26,8 @@ final class Options private const DEFAULT_SIGNATURE = null; public function __construct( - public readonly null|int|string $width = self::DEFAULT_WIDTH, - public readonly null|int|string $height = self::DEFAULT_HEIGHT, + public readonly ?int $width = self::DEFAULT_WIDTH, + public readonly ?int $height = self::DEFAULT_HEIGHT, public readonly ?string $watermarkUrl = self::DEFAULT_WATERMARK_URL, public readonly string $watermarkGravity = self::DEFAULT_WATERMARK_POSITION, public readonly int $watermarkScale = self::DEFAULT_WATERMARK_SCALE, @@ -86,9 +86,12 @@ public function setSignature(string $signature): self /** @param array $options */ public static function fromArray(array $options): self { + $width = $options['width'] ?? $options['w'] ?? null; + $height = $options['height'] ?? $options['h'] ?? null; + return new self( - $options['width'] ?? $options['w'] ?? self::DEFAULT_WIDTH, - $options['height'] ?? $options['h'] ?? self::DEFAULT_HEIGHT, + null !== $width ? (int) $width : self::DEFAULT_WIDTH, + null !== $height ? (int) $height : self::DEFAULT_HEIGHT, $options['watermarkUrl'] ?? $options['wu'] ?? $options['wat_url'] ?? self::DEFAULT_WATERMARK_URL, $options['watermarkPosition'] ?? $options['wp'] ?? $options['wat_position'] ?? self::DEFAULT_WATERMARK_POSITION, (int) ($options['watermarkScale'] ?? $options['ws'] ?? $options['wat_scale'] ?? self::DEFAULT_WATERMARK_SCALE), diff --git a/src/Proxy.php b/src/Proxy.php index e9d4232..e90a67c 100644 --- a/src/Proxy.php +++ b/src/Proxy.php @@ -107,6 +107,6 @@ public function response(string $file, ?Options $options = null, array $headers private function exists(string $file): bool { - return $this->filesystem->exists($this->assetsPath . $this->normalizeFile($file)); + return $this->filesystem->exists($this->assetsPath . $file); } } diff --git a/src/Signer.php b/src/Signer.php index 7eb3d9b..9e31bfd 100644 --- a/src/Signer.php +++ b/src/Signer.php @@ -13,8 +13,6 @@ namespace BaBeuloula\CdnPhpBundle; -use Defuse\Crypto\Crypto; - final class Signer { public function __construct( @@ -40,12 +38,12 @@ public function sign(Options $options): string public function isValid(Options $options): bool { - return $this->calcSignature($options) === $options->signature; + return hash_equals($this->calcSignature($options), (string) $options->signature); } public function calcSignature(Options $options): string { - return sha1($options->buildQuery(false) . $this->getSecretKey()); + return hash_hmac('sha256', $options->buildQuery(false), $this->getSecretKey()); } public function isCdnSigningEnabled(): bool diff --git a/src/Twig/Extension/ProxyExtension.php b/src/Twig/Extension/ProxyExtension.php index bdf37b9..ff18ebb 100644 --- a/src/Twig/Extension/ProxyExtension.php +++ b/src/Twig/Extension/ProxyExtension.php @@ -26,7 +26,8 @@ public function __construct( private readonly RouterInterface $router, private readonly string $routeName, private readonly string $routeParameter, - private readonly Signer $encrypter, + private readonly Signer $signer, + private readonly bool $encryptedParameters = false, ) { } @@ -39,13 +40,12 @@ public function getFunctions(): array } /** @param array $options */ - public function cdnPhp(string $file, array $options = [], bool $enableEncrypter = true): string + public function cdnPhp(string $file, array $options = [], ?bool $enableEncrypter = null): string { $options = Options::fromArray($options); - $queryParams = (true === $enableEncrypter) - ? '?' . $this->encrypter->sign($options) - : '?' . $options->buildQuery() - ; + $useEncrypter = $enableEncrypter ?? $this->encryptedParameters; + $query = true === $useEncrypter ? $this->signer->sign($options) : $options->buildQuery(); + $queryParams = '' !== $query ? '?' . $query : ''; return $this->router->generate( $this->routeName, diff --git a/tests/AbstractHandlerTest.php b/tests/AbstractHandlerTest.php new file mode 100644 index 0000000..fb88e98 --- /dev/null +++ b/tests/AbstractHandlerTest.php @@ -0,0 +1,133 @@ + + * @copyright Copyright (c) BaBeuloula + * @license MIT + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace BaBeuloula\CdnPhpBundle\Tests; + +use BaBeuloula\CdnPhpBundle\AbstractHandler; +use BaBeuloula\CdnPhpBundle\Options; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; + +final class AbstractHandlerTest extends TestCase +{ + private function makeHandler(string $assetsPath): AbstractHandler + { + // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + return new class ($assetsPath) extends AbstractHandler { + // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + public function response(string $file, ?Options $options = null, array $headers = []): Response + { + return new Response(); + } + }; + } + + private function getAssetsPath(AbstractHandler $handler): string + { + /** @var string $value */ + $value = (new \ReflectionProperty(AbstractHandler::class, 'assetsPath'))->getValue($handler); + + return $value; + } + + private function normalizeFile(AbstractHandler $handler, string $file): string + { + /** @var string $value */ + $value = (new \ReflectionMethod(AbstractHandler::class, 'normalizeFile'))->invoke($handler, $file); + + return $value; + } + + public function testAssetsPathAddsTrailingSlash(): void + { + self::assertSame('/assets/', $this->getAssetsPath($this->makeHandler('/assets'))); + } + + public function testAssetsPathStripsExtraTrailingSlashes(): void + { + self::assertSame('/assets/', $this->getAssetsPath($this->makeHandler('/assets///'))); + } + + public function testAssetsPathAlreadyWithTrailingSlash(): void + { + self::assertSame('/assets/', $this->getAssetsPath($this->makeHandler('/assets/'))); + } + + public function testNormalizeFileStripsLeadingSlash(): void + { + self::assertSame('images/foo.jpg', $this->normalizeFile($this->makeHandler('/assets/'), '/images/foo.jpg')); + } + + public function testNormalizeFileWithNoLeadingSlash(): void + { + self::assertSame('images/foo.jpg', $this->normalizeFile($this->makeHandler('/assets/'), 'images/foo.jpg')); + } + + public function testParseHeadersExtractsKnownHeaders(): void + { + $request = Request::create( + '/', + 'GET', + [], + [], + [], + [ + 'HTTP_ACCEPT' => 'image/webp,image/*', + 'HTTP_USER_AGENT' => 'Mozilla/5.0', + 'HTTP_ACCEPT_LANGUAGE' => 'fr-FR', + ] + ); + + $headers = $this->makeHandler('/assets/')->parseHeaders($request); + + self::assertArrayHasKey('accept', $headers); + self::assertArrayHasKey('user-agent', $headers); + self::assertArrayHasKey('accept-language', $headers); + self::assertSame('image/webp,image/*', $headers['accept']); + self::assertSame('Mozilla/5.0', $headers['user-agent']); + self::assertSame('fr-FR', $headers['accept-language']); + } + + public function testParseHeadersIgnoresUnknownHeaders(): void + { + $request = Request::create( + '/', + 'GET', + [], + [], + [], + [ + 'HTTP_X_CUSTOM' => 'value', + 'HTTP_AUTHORIZATION' => 'Bearer token', + ] + ); + + $headers = $this->makeHandler('/assets/')->parseHeaders($request); + + self::assertArrayNotHasKey('x-custom', $headers); + self::assertArrayNotHasKey('authorization', $headers); + } + + public function testParseHeadersOmitsAbsentHeaders(): void + { + // Use new Request() directly to avoid Request::create() defaults (HTTP_ACCEPT, HTTP_ACCEPT_LANGUAGE) + $request = new Request([], [], [], [], [], ['HTTP_USER_AGENT' => 'Mozilla/5.0']); + + $headers = $this->makeHandler('/assets/')->parseHeaders($request); + + self::assertArrayHasKey('user-agent', $headers); + self::assertArrayNotHasKey('accept', $headers); + self::assertArrayNotHasKey('accept-language', $headers); + } +} diff --git a/tests/Exception/FetchAssetExceptionTest.php b/tests/Exception/FetchAssetExceptionTest.php new file mode 100644 index 0000000..10efb5a --- /dev/null +++ b/tests/Exception/FetchAssetExceptionTest.php @@ -0,0 +1,38 @@ + + * @copyright Copyright (c) BaBeuloula + * @license MIT + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace BaBeuloula\CdnPhpBundle\Tests\Exception; + +use BaBeuloula\CdnPhpBundle\Exception\FetchAssetException; +use PHPUnit\Framework\TestCase; + +final class FetchAssetExceptionTest extends TestCase +{ + public function testIsInstanceOfException(): void + { + self::assertInstanceOf(\Exception::class, new FetchAssetException()); + } + + public function testAcceptsMessage(): void + { + $exception = new FetchAssetException('fetch failed'); + self::assertSame('fetch failed', $exception->getMessage()); + } + + public function testAcceptsPreviousException(): void + { + $previous = new \RuntimeException('original'); + $exception = new FetchAssetException('', 0, $previous); + self::assertSame($previous, $exception->getPrevious()); + } +} diff --git a/tests/Exception/FileNotFoundExceptionTest.php b/tests/Exception/FileNotFoundExceptionTest.php new file mode 100644 index 0000000..77c42f2 --- /dev/null +++ b/tests/Exception/FileNotFoundExceptionTest.php @@ -0,0 +1,38 @@ + + * @copyright Copyright (c) BaBeuloula + * @license MIT + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace BaBeuloula\CdnPhpBundle\Tests\Exception; + +use BaBeuloula\CdnPhpBundle\Exception\FileNotFoundException; +use PHPUnit\Framework\TestCase; + +final class FileNotFoundExceptionTest extends TestCase +{ + public function testIsInstanceOfException(): void + { + self::assertInstanceOf(\Exception::class, new FileNotFoundException()); + } + + public function testAcceptsMessage(): void + { + $exception = new FileNotFoundException('file not found'); + self::assertSame('file not found', $exception->getMessage()); + } + + public function testAcceptsPreviousException(): void + { + $previous = new \RuntimeException('original'); + $exception = new FileNotFoundException('', 0, $previous); + self::assertSame($previous, $exception->getPrevious()); + } +} diff --git a/tests/FallbackHandler/InterventionImageFallbackHandlerTest.php b/tests/FallbackHandler/InterventionImageFallbackHandlerTest.php new file mode 100644 index 0000000..9633c2e --- /dev/null +++ b/tests/FallbackHandler/InterventionImageFallbackHandlerTest.php @@ -0,0 +1,203 @@ + + * @copyright Copyright (c) BaBeuloula + * @license MIT + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace BaBeuloula\CdnPhpBundle\Tests\FallbackHandler; + +use BaBeuloula\CdnPhpBundle\Exception\FileNotFoundException; +use BaBeuloula\CdnPhpBundle\FallbackHandler\InterventionImageFallbackHandler; +use BaBeuloula\CdnPhpBundle\Options; +use Intervention\Image\Interfaces\DriverInterface; +use PHPUnit\Framework\Attributes\RequiresPhpExtension; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Contracts\Cache\CacheInterface; + +final class InterventionImageFallbackHandlerTest extends TestCase +{ + private const FIXTURES_PATH = __DIR__ . '/../Fixtures/'; + + /** @param array $headers */ + private function makeCacheCapturingKey(array $headers, ?Options $options = null): string + { + /** @var DriverInterface&MockObject $driver */ + $driver = $this->createMock(DriverInterface::class); + /** @var CacheInterface&MockObject $cache */ + $cache = $this->createMock(CacheInterface::class); + + $capturedKey = ''; + $cache->method('get') + ->willReturnCallback( + static function (string $key) use (&$capturedKey): array { + $capturedKey = $key; + + return ['content' => '', 'mimetype' => 'image/png']; + } + ); + + $handler = new InterventionImageFallbackHandler($driver, self::FIXTURES_PATH, $cache); + $handler->response('test.png', $options, $headers); + + return $capturedKey; + } + + public function testFormatKeyIsAvifWhenAcceptContainsAvif(): void + { + $key = $this->makeCacheCapturingKey(['accept' => 'image/avif,image/webp']); + self::assertStringEndsWith('.avif', $key); + } + + public function testFormatKeyIsWebpWhenAcceptContainsWebp(): void + { + $key = $this->makeCacheCapturingKey(['accept' => 'image/webp']); + self::assertStringEndsWith('.webp', $key); + } + + public function testFormatKeyIsOriginalWhenNoAcceptHeader(): void + { + $key = $this->makeCacheCapturingKey([]); + self::assertStringEndsWith('.original', $key); + } + + public function testFormatKeyIsAvifOverWebpWhenBothAccepted(): void + { + $key = $this->makeCacheCapturingKey(['accept' => 'image/avif,image/webp,image/*']); + self::assertStringEndsWith('.avif', $key); + } + + public function testCacheKeyIncludesFile(): void + { + $key = $this->makeCacheCapturingKey([]); + self::assertStringContainsString('test', $key); + } + + public function testCacheKeyDiffersForDifferentOptions(): void + { + $keyA = $this->makeCacheCapturingKey([], new Options(200)); + $keyB = $this->makeCacheCapturingKey([], new Options(300)); + + self::assertNotSame($keyA, $keyB); + } + + public function testCacheKeyDiffersForDifferentFormats(): void + { + $keyAvif = $this->makeCacheCapturingKey(['accept' => 'image/avif']); + $keyWebp = $this->makeCacheCapturingKey(['accept' => 'image/webp']); + $keyOrig = $this->makeCacheCapturingKey([]); + + self::assertNotSame($keyAvif, $keyWebp); + self::assertNotSame($keyAvif, $keyOrig); + self::assertNotSame($keyWebp, $keyOrig); + } + + #[RequiresPhpExtension('gd')] + public function testCacheMissProcessesImageAndReturnsResponse(): void + { + $driver = new \Intervention\Image\Drivers\Gd\Driver(); + $handler = new InterventionImageFallbackHandler($driver, self::FIXTURES_PATH, new ArrayAdapter()); + + $response = $handler->response('test.png'); + + self::assertSame(200, $response->getStatusCode()); + self::assertNotEmpty($response->getContent()); + } + + #[RequiresPhpExtension('gd')] + public function testCacheHitSkipsProcessingAndReturnsResponse(): void + { + $driver = new \Intervention\Image\Drivers\Gd\Driver(); + $cache = new ArrayAdapter(); + $handler = new InterventionImageFallbackHandler($driver, self::FIXTURES_PATH, $cache); + + // First call populates cache + $handler->response('test.png'); + + // Second call should hit the cache (same result, no re-processing) + $response = $handler->response('test.png'); + self::assertSame(200, $response->getStatusCode()); + } + + #[RequiresPhpExtension('gd')] + public function testResponseSetsCorrectContentType(): void + { + $driver = new \Intervention\Image\Drivers\Gd\Driver(); + $handler = new InterventionImageFallbackHandler($driver, self::FIXTURES_PATH, new ArrayAdapter()); + + $response = $handler->response('test.png'); + + self::assertNotEmpty($response->headers->get('Content-Type')); + } + + #[RequiresPhpExtension('gd')] + public function testResponseContentTypeReplacesPassedHeaders(): void + { + $driver = new \Intervention\Image\Drivers\Gd\Driver(); + $handler = new InterventionImageFallbackHandler($driver, self::FIXTURES_PATH, new ArrayAdapter()); + + $response = $handler->response('test.png', null, ['content-type' => 'wrong/type']); + $contentType = $response->headers->get('Content-Type'); + + self::assertNotNull($contentType); + self::assertNotSame('wrong/type', $contentType); + } + + #[RequiresPhpExtension('gd')] + public function testDecoderExceptionThrowsFileNotFoundException(): void + { + $driver = new \Intervention\Image\Drivers\Gd\Driver(); + $handler = new InterventionImageFallbackHandler($driver, self::FIXTURES_PATH, new ArrayAdapter()); + + $this->expectException(FileNotFoundException::class); + $handler->response('nonexistent-file-that-does-not-exist.jpg'); + } + + #[RequiresPhpExtension('gd')] + public function testBothDimensionsUsesCover(): void + { + $driver = new \Intervention\Image\Drivers\Gd\Driver(); + $handler = new InterventionImageFallbackHandler($driver, self::FIXTURES_PATH, new ArrayAdapter()); + + $response = $handler->response('test.png', new Options(5, 5)); + self::assertSame(200, $response->getStatusCode()); + } + + #[RequiresPhpExtension('gd')] + public function testWidthOnlyScalesImage(): void + { + $driver = new \Intervention\Image\Drivers\Gd\Driver(); + $handler = new InterventionImageFallbackHandler($driver, self::FIXTURES_PATH, new ArrayAdapter()); + + $response = $handler->response('test.png', new Options(5)); + self::assertSame(200, $response->getStatusCode()); + } + + #[RequiresPhpExtension('gd')] + public function testHeightOnlyScalesImage(): void + { + $driver = new \Intervention\Image\Drivers\Gd\Driver(); + $handler = new InterventionImageFallbackHandler($driver, self::FIXTURES_PATH, new ArrayAdapter()); + + $response = $handler->response('test.png', new Options(null, 5)); + self::assertSame(200, $response->getStatusCode()); + } + + #[RequiresPhpExtension('gd')] + public function testWebpEncodingWhenAccepted(): void + { + $driver = new \Intervention\Image\Drivers\Gd\Driver(); + $handler = new InterventionImageFallbackHandler($driver, self::FIXTURES_PATH, new ArrayAdapter()); + + $response = $handler->response('test.png', null, ['accept' => 'image/webp']); + self::assertSame('image/webp', $response->headers->get('Content-Type')); + } +} diff --git a/tests/Fixtures/test.png b/tests/Fixtures/test.png new file mode 100644 index 0000000..d0a5700 Binary files /dev/null and b/tests/Fixtures/test.png differ diff --git a/tests/OptionsTest.php b/tests/OptionsTest.php new file mode 100644 index 0000000..2f4c6aa --- /dev/null +++ b/tests/OptionsTest.php @@ -0,0 +1,164 @@ + + * @copyright Copyright (c) BaBeuloula + * @license MIT + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace BaBeuloula\CdnPhpBundle\Tests; + +use BaBeuloula\CdnPhpBundle\Options; +use PHPUnit\Framework\TestCase; + +final class OptionsTest extends TestCase +{ + public function testToArrayEmpty(): void + { + self::assertSame([], (new Options())->toArray()); + } + + public function testToArrayWithDimensions(): void + { + $result = (new Options(200, 100))->toArray(); + self::assertSame(['w' => 200, 'h' => 100], $result); + } + + public function testToArrayExcludesNullValues(): void + { + $result = (new Options(width: 200))->toArray(); + self::assertArrayHasKey('w', $result); + self::assertArrayNotHasKey('h', $result); + } + + public function testToArrayWithWatermarkIncludesWatermarkKeys(): void + { + $result = (new Options(watermarkUrl: 'http://example.com/wm.png'))->toArray(); + self::assertArrayHasKey('wu', $result); + self::assertArrayHasKey('wp', $result); + self::assertArrayHasKey('ws', $result); + self::assertArrayHasKey('wo', $result); + } + + public function testToArrayWithoutWatermarkExcludesWatermarkKeys(): void + { + $result = (new Options(200))->toArray(); + self::assertArrayNotHasKey('wu', $result); + self::assertArrayNotHasKey('wp', $result); + self::assertArrayNotHasKey('ws', $result); + self::assertArrayNotHasKey('wo', $result); + } + + public function testToArrayIncludesSignatureWhenRequested(): void + { + $result = (new Options(signature: 'abc123'))->toArray(true); + self::assertArrayHasKey(Options::SIGNATURE_KEY, $result); + self::assertSame('abc123', $result[Options::SIGNATURE_KEY]); + } + + public function testToArrayExcludesSignatureWhenFalse(): void + { + $result = (new Options(signature: 'abc123'))->toArray(false); + self::assertArrayNotHasKey(Options::SIGNATURE_KEY, $result); + } + + public function testBuildQueryEmpty(): void + { + self::assertSame('', (new Options())->buildQuery()); + } + + public function testBuildQueryWithDimensions(): void + { + self::assertSame('w=200&h=100', (new Options(200, 100))->buildQuery()); + } + + public function testBuildQueryExcludesSignatureWhenFalse(): void + { + $query = (new Options(200, signature: 'sig'))->buildQuery(false); + self::assertStringNotContainsString('signature', $query); + } + + public function testHasSignatureReturnsFalseByDefault(): void + { + self::assertFalse((new Options())->hasSignature()); + } + + public function testHasSignatureReturnsTrueWhenSet(): void + { + self::assertTrue((new Options(signature: 'abc'))->hasSignature()); + } + + public function testSetSignatureReturnsNewImmutableInstance(): void + { + $original = new Options(200); + $new = $original->setSignature('xyz'); + + self::assertNotSame($original, $new); + self::assertSame('xyz', $new->signature); + self::assertNull($original->signature); + } + + public function testSetSignaturePreservesOtherValues(): void + { + $original = new Options(200, 100, watermarkUrl: 'http://example.com/wm.png'); + $new = $original->setSignature('xyz'); + + self::assertSame(200, $new->width); + self::assertSame(100, $new->height); + self::assertSame('http://example.com/wm.png', $new->watermarkUrl); + } + + public function testFromArrayWithShortAliases(): void + { + $options = Options::fromArray(['w' => 200, 'h' => 100]); + self::assertSame(200, $options->width); + self::assertSame(100, $options->height); + } + + public function testFromArrayWithLongAliases(): void + { + $options = Options::fromArray(['width' => 300, 'height' => 150]); + self::assertSame(300, $options->width); + self::assertSame(150, $options->height); + } + + public function testFromArrayWithWatLegacyAliases(): void + { + $options = Options::fromArray( + [ + 'wat_url' => 'http://example.com/wm.png', + 'wat_position' => 'top', + 'wat_scale' => 80, + 'wat_opacity' => 40, + ] + ); + + self::assertSame('http://example.com/wm.png', $options->watermarkUrl); + self::assertSame('top', $options->watermarkGravity); + self::assertSame(80, $options->watermarkScale); + self::assertSame(40, $options->watermarkOpacity); + } + + public function testFromArrayWithEmptyArrayUsesDefaults(): void + { + $options = Options::fromArray([]); + self::assertNull($options->width); + self::assertNull($options->height); + self::assertNull($options->watermarkUrl); + self::assertSame('center', $options->watermarkGravity); + self::assertSame(75, $options->watermarkScale); + self::assertSame(50, $options->watermarkOpacity); + } + + public function testFromArrayNormalizesStringDimensionsToInt(): void + { + $options = Options::fromArray(['w' => '200', 'h' => '100']); + self::assertSame(200, $options->width); + self::assertSame(100, $options->height); + } +} diff --git a/tests/ProxyTest.php b/tests/ProxyTest.php new file mode 100644 index 0000000..98b5c72 --- /dev/null +++ b/tests/ProxyTest.php @@ -0,0 +1,281 @@ + + * @copyright Copyright (c) BaBeuloula + * @license MIT + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace BaBeuloula\CdnPhpBundle\Tests; + +use BaBeuloula\CdnPhpBundle\Exception\FetchAssetException; +use BaBeuloula\CdnPhpBundle\Exception\FileNotFoundException; +use BaBeuloula\CdnPhpBundle\FallbackHandler\FallbackHandlerInterface; +use BaBeuloula\CdnPhpBundle\Options; +use BaBeuloula\CdnPhpBundle\Proxy; +use BaBeuloula\CdnPhpBundle\Signer; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\EventListener\AbstractSessionListener; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; + +final class ProxyTest extends TestCase +{ + private const CDN_URL = 'http://cdn.example.com/'; + private const ASSETS_PATH = '/var/www/assets/'; + + /** @var Filesystem&MockObject */ + private Filesystem $filesystem; + + protected function setUp(): void + { + $this->filesystem = $this->createMock(Filesystem::class); + } + + private function makeProxy( + MockHttpClient $client, + bool $checkAssets = false, + ?FallbackHandlerInterface $fallback = null, + ?Signer $signer = null, + ): Proxy { + return new Proxy( + self::ASSETS_PATH, + $checkAssets, + $this->filesystem, + $client, + self::CDN_URL, + $signer ?? new Signer(), + $fallback, + ); + } + + public function testResponseFetchesCdnAndCopiesWhitelistedHeaders(): void + { + $client = new MockHttpClient( + new MockResponse( + 'img-content', + [ + 'response_headers' => [ + 'content-type' => ['image/jpeg'], + 'cache-control' => ['max-age=3600'], + 'etag' => ['"abc123"'], + 'last-modified' => ['Mon, 01 Jan 2024 00:00:00 GMT'], + 'expires' => ['Mon, 01 Jan 2025 00:00:00 GMT'], + 'content-encoding' => ['identity'], + 'content-length' => ['11'], + 'vary' => ['Accept'], + 'x-content-type-options' => ['nosniff'], + 'x-dominant-color' => ['#ff0000'], + ], + ] + ) + ); + + $response = $this->makeProxy($client)->response('image.jpg'); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame('img-content', $response->getContent()); + self::assertSame('image/jpeg', $response->headers->get('content-type')); + self::assertStringContainsString('max-age=3600', (string) $response->headers->get('cache-control')); + self::assertSame('"abc123"', $response->headers->get('etag')); + self::assertSame('nosniff', $response->headers->get('x-content-type-options')); + self::assertSame('#ff0000', $response->headers->get('x-dominant-color')); + } + + public function testResponseSetsNoCacheControlHeader(): void + { + $client = new MockHttpClient(new MockResponse('content')); + $response = $this->makeProxy($client)->response('image.jpg'); + + self::assertSame('true', $response->headers->get(AbstractSessionListener::NO_AUTO_CACHE_CONTROL_HEADER)); + } + + public function testResponseIgnoresNonWhitelistedHeaders(): void + { + $client = new MockHttpClient( + new MockResponse( + 'content', + [ + 'response_headers' => ['x-secret' => ['value']], + ] + ) + ); + + $response = $this->makeProxy($client)->response('image.jpg'); + + self::assertFalse($response->headers->has('x-secret')); + } + + public function testResponseNormalizesFileStrippingLeadingSlash(): void + { + $requestedUrl = ''; + $client = new MockHttpClient( + // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + static function (string $method, string $url) use (&$requestedUrl): MockResponse { + $requestedUrl = $url; + + return new MockResponse('content'); + } + ); + + $this->makeProxy($client)->response('/image.jpg'); + + self::assertStringContainsString(self::CDN_URL . 'image.jpg', $requestedUrl); + self::assertStringNotContainsString('//image.jpg', $requestedUrl); + } + + public function testCheckAssetsEnabledFileExists(): void + { + $this->filesystem->expects(self::once()) + ->method('exists') + ->willReturn(true); + + $client = new MockHttpClient(new MockResponse('content')); + $this->makeProxy($client, checkAssets: true)->response('image.jpg'); + } + + public function testCheckAssetsEnabledFileMissingThrowsFileNotFoundException(): void + { + $this->filesystem->expects(self::once()) + ->method('exists') + ->willReturn(false); + + $client = new MockHttpClient(new MockResponse('content')); + + $this->expectException(FileNotFoundException::class); + $this->makeProxy($client, checkAssets: true)->response('image.jpg'); + } + + public function testCheckAssetsDisabledNeverCallsFilesystem(): void + { + $this->filesystem->expects(self::never())->method('exists'); + + $client = new MockHttpClient(new MockResponse('content')); + $this->makeProxy($client, checkAssets: false)->response('image.jpg'); + } + + public function testResponseWithOptionsBuildsQueryString(): void + { + $requestedUrl = ''; + $client = new MockHttpClient( + // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + static function (string $method, string $url) use (&$requestedUrl): MockResponse { + $requestedUrl = $url; + + return new MockResponse('content'); + } + ); + + $this->makeProxy($client)->response('image.jpg', new Options(200, 100)); + + self::assertStringContainsString('w=200&h=100', $requestedUrl); + } + + public function testResponseWithCdnSigningAppendsExpiresAndSig(): void + { + $requestedUrl = ''; + $client = new MockHttpClient( + // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + static function (string $method, string $url) use (&$requestedUrl): MockResponse { + $requestedUrl = $url; + + return new MockResponse('content'); + } + ); + + $signer = new Signer(null, 'test-cdn-secret'); + $this->makeProxy($client, signer: $signer)->response('image.jpg'); + + self::assertStringContainsString('expires=', $requestedUrl); + self::assertStringContainsString('sig=', $requestedUrl); + } + + public function testResponseWithCdnSigningAndOptionsAppendsToExistingQuery(): void + { + $requestedUrl = ''; + $client = new MockHttpClient( + // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + static function (string $method, string $url) use (&$requestedUrl): MockResponse { + $requestedUrl = $url; + + return new MockResponse('content'); + } + ); + + $signer = new Signer(null, 'test-cdn-secret'); + $this->makeProxy($client, signer: $signer)->response('image.jpg', new Options(200)); + + self::assertStringContainsString('w=200', $requestedUrl); + self::assertStringContainsString('expires=', $requestedUrl); + self::assertStringContainsString('sig=', $requestedUrl); + } + + public function testHttpTimeoutIs25(): void + { + $capturedOptions = []; + $client = new MockHttpClient( + // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + static function (string $method, string $url, array $options) use (&$capturedOptions): MockResponse { + $capturedOptions = $options; + + return new MockResponse('content'); + } + ); + + $this->makeProxy($client)->response('image.jpg'); + + self::assertSame(25.0, $capturedOptions['timeout']); + } + + public function testExceptionWithFallbackCallsFallback(): void + { + $client = new MockHttpClient( + static function (): never { + throw new \RuntimeException('CDN unavailable', 503); + } + ); + + $fallbackResponse = new Response('fallback-content', 200, ['content-type' => 'image/jpeg']); + $fallback = $this->createMock(FallbackHandlerInterface::class); + $fallback->expects(self::once()) + ->method('response') + ->willReturn($fallbackResponse); + + $response = $this->makeProxy($client, fallback: $fallback)->response('image.jpg'); + + self::assertSame('fallback-content', $response->getContent()); + } + + public function testExceptionWithout404ThrowsFetchAssetException(): void + { + $client = new MockHttpClient( + static function (): never { + throw new \RuntimeException('Server error', 500); + } + ); + + $this->expectException(FetchAssetException::class); + $this->makeProxy($client)->response('image.jpg'); + } + + public function testExceptionWith404ThrowsNotFoundHttpException(): void + { + $client = new MockHttpClient( + static function (): never { + throw new \RuntimeException('Not found', Response::HTTP_NOT_FOUND); + } + ); + + $this->expectException(NotFoundHttpException::class); + $this->makeProxy($client)->response('image.jpg'); + } +} diff --git a/tests/SignerTest.php b/tests/SignerTest.php new file mode 100644 index 0000000..2900ad5 --- /dev/null +++ b/tests/SignerTest.php @@ -0,0 +1,122 @@ + + * @copyright Copyright (c) BaBeuloula + * @license MIT + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace BaBeuloula\CdnPhpBundle\Tests; + +use BaBeuloula\CdnPhpBundle\Options; +use BaBeuloula\CdnPhpBundle\Signer; +use PHPUnit\Framework\TestCase; + +final class SignerTest extends TestCase +{ + public function testIsEnabledWithNull(): void + { + self::assertFalse((new Signer(null))->isEnabled()); + } + + public function testIsEnabledWithEmptyString(): void + { + self::assertFalse((new Signer(''))->isEnabled()); + } + + public function testIsEnabledWithKey(): void + { + self::assertTrue((new Signer('secret'))->isEnabled()); + } + + public function testCalcSignatureIsDeterministic(): void + { + $signer = new Signer('my-secret'); + $options = new Options(200, 100); + + self::assertSame($signer->calcSignature($options), $signer->calcSignature($options)); + } + + public function testCalcSignatureMatchesExpectedHmac(): void + { + $signer = new Signer('my-secret'); + $options = new Options(200, 100); + $expected = hash_hmac('sha256', $options->buildQuery(false), 'my-secret'); + + self::assertSame($expected, $signer->calcSignature($options)); + } + + public function testSignWithDisabledSignerReturnsRawQuery(): void + { + $query = (new Signer(null))->sign(new Options(200)); + self::assertStringNotContainsString('signature=', $query); + self::assertSame('w=200', $query); + } + + public function testSignWithEnabledSignerAddsSignature(): void + { + $query = (new Signer('key'))->sign(new Options(200)); + self::assertStringContainsString('signature=', $query); + } + + public function testIsValidReturnsTrueForCorrectSignature(): void + { + $signer = new Signer('key'); + $options = new Options(200, 100); + $signature = $signer->calcSignature($options); + $signed = new Options(200, 100, signature: $signature); + + self::assertTrue($signer->isValid($signed)); + } + + public function testIsValidReturnsFalseForWrongSignature(): void + { + $signer = new Signer('key'); + $options = new Options(200, 100, signature: 'wrong-signature'); + + self::assertFalse($signer->isValid($options)); + } + + public function testIsCdnSigningEnabledWithNull(): void + { + self::assertFalse((new Signer(null, null))->isCdnSigningEnabled()); + } + + public function testIsCdnSigningEnabledWithKey(): void + { + self::assertTrue((new Signer(null, 'cdn-key'))->isCdnSigningEnabled()); + } + + public function testSignCdnRequestReturnsExpiresAndSig(): void + { + $result = (new Signer(null, 'cdn-key'))->signCdnRequest('images/foo.jpg'); + + self::assertArrayHasKey('expires', $result); + self::assertArrayHasKey('sig', $result); + self::assertIsInt($result['expires']); + self::assertIsString($result['sig']); + } + + public function testSignCdnRequestExpiresApproximatesNowPlusTtl(): void + { + $ttl = 3600; + $result = (new Signer(null, 'cdn-key', $ttl))->signCdnRequest('img.jpg'); + $expected = time() + $ttl; + + self::assertEqualsWithDelta($expected, $result['expires'], 5.0); + } + + public function testSignCdnRequestNormalizesLeadingSlash(): void + { + $signer = new Signer(null, 'cdn-key', 3600); + $withSlash = $signer->signCdnRequest('/images/foo.jpg'); + $withoutSlash = $signer->signCdnRequest('images/foo.jpg'); + + self::assertSame($withSlash['sig'], $withoutSlash['sig']); + } +} diff --git a/tests/Twig/Extension/ProxyExtensionTest.php b/tests/Twig/Extension/ProxyExtensionTest.php new file mode 100644 index 0000000..7f2da9d --- /dev/null +++ b/tests/Twig/Extension/ProxyExtensionTest.php @@ -0,0 +1,159 @@ + + * @copyright Copyright (c) BaBeuloula + * @license MIT + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace BaBeuloula\CdnPhpBundle\Tests\Twig\Extension; + +use BaBeuloula\CdnPhpBundle\Signer; +use BaBeuloula\CdnPhpBundle\Twig\Extension\ProxyExtension; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\Component\Routing\RouterInterface; +use Twig\TwigFunction; + +final class ProxyExtensionTest extends TestCase +{ + private const ROUTE_NAME = 'app_cdn'; + private const ROUTE_PARAMETER = 'path'; + private const GENERATED_URL = 'http://example.com/cdn/image.jpg'; + + /** @var RouterInterface&MockObject */ + private RouterInterface $router; + + protected function setUp(): void + { + $this->router = $this->createMock(RouterInterface::class); + $this->router->method('generate')->willReturn(self::GENERATED_URL); + } + + private function makeExtension(bool $encryptedParameters = false, ?Signer $signer = null): ProxyExtension + { + return new ProxyExtension( + $this->router, + self::ROUTE_NAME, + self::ROUTE_PARAMETER, + $signer ?? new Signer(), + $encryptedParameters, + ); + } + + public function testGetFunctionsReturnsTwoFunctions(): void + { + $functions = $this->makeExtension()->getFunctions(); + + self::assertCount(2, $functions); + self::assertContainsOnlyInstancesOf(TwigFunction::class, $functions); + + $names = array_map(static fn (TwigFunction $f) => $f->getName(), $functions); + self::assertContains('cdn_php', $names); + self::assertContains('cdn', $names); + } + + public function testCdnPhpWithNoOptionsAndEncryptionDisabledReturnsUrl(): void + { + $url = $this->makeExtension()->cdnPhp('image.jpg'); + + self::assertSame(self::GENERATED_URL, $url); + } + + public function testCdnPhpWithOptionsAppendsQueryString(): void + { + $url = $this->makeExtension()->cdnPhp('image.jpg', ['w' => 200]); + + self::assertStringContainsString('?', $url); + self::assertStringContainsString('w=200', $url); + } + + public function testCdnPhpWithEmptyQueryNoTrailingQuestionMark(): void + { + $url = $this->makeExtension()->cdnPhp('image.jpg', []); + + self::assertStringEndsNotWith('?', $url); + } + + public function testCdnPhpStripsLeadingSlashFromFile(): void + { + $this->router->expects(self::once()) + ->method('generate') + ->with( + self::ROUTE_NAME, + [self::ROUTE_PARAMETER => 'image.jpg'], + UrlGeneratorInterface::ABSOLUTE_URL, + ) + ->willReturn(self::GENERATED_URL); + + $this->makeExtension()->cdnPhp('/image.jpg'); + } + + public function testCdnPhpCallsRouterWithAbsoluteUrl(): void + { + $this->router->expects(self::once()) + ->method('generate') + ->with( + self::anything(), + self::anything(), + UrlGeneratorInterface::ABSOLUTE_URL, + ) + ->willReturn(self::GENERATED_URL); + + $this->makeExtension()->cdnPhp('image.jpg'); + } + + public function testCdnPhpWithEncryptionEnabledAddsSignature(): void + { + $url = $this->makeExtension( + encryptedParameters: true, + signer: new Signer('test-secret'), + )->cdnPhp('image.jpg', ['w' => 200]); + + self::assertStringContainsString('signature=', $url); + } + + public function testCdnPhpWithEncryptionDisabledNoSignature(): void + { + $url = $this->makeExtension( + encryptedParameters: false, + signer: new Signer('test-secret'), + )->cdnPhp('image.jpg', ['w' => 200]); + + self::assertStringNotContainsString('signature=', $url); + } + + public function testExplicitEnableEncrypterTrueOverridesDefaultFalse(): void + { + $url = $this->makeExtension( + encryptedParameters: false, + signer: new Signer('test-secret'), + )->cdnPhp('image.jpg', ['w' => 200], true); + + self::assertStringContainsString('signature=', $url); + } + + public function testExplicitEnableEncrypterFalseOverridesDefaultTrue(): void + { + $url = $this->makeExtension( + encryptedParameters: true, + signer: new Signer('test-secret'), + )->cdnPhp('image.jpg', ['w' => 200], false); + + self::assertStringNotContainsString('signature=', $url); + } + + public function testCdnPhpWithEncryptionAndEmptyQueryAppendsNothing(): void + { + // Disabled signer (null key) returns empty query for options with no values + $url = $this->makeExtension(encryptedParameters: true)->cdnPhp('image.jpg'); + + self::assertStringEndsNotWith('?', $url); + } +}