feat: add image content validator

This commit is contained in:
Keith Solomon
2026-07-06 18:32:50 -05:00
parent 288c42e176
commit 6399e2d86c
4 changed files with 286 additions and 0 deletions
@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Unit\Application;
use BattleForge\Application\ImageValidator;
use BattleForge\Application\InvalidImageException;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class ImageValidatorTest extends TestCase
{
private const TMP_DIR = '/tmp';
public function testItAcceptsAValidPng(): void
{
$path = self::TMP_DIR . '/bf-valid-' . bin2hex(random_bytes(4)) . '.png';
self::assertNotFalse(file_put_contents($path, self::validPng(8, 8)));
try {
$result = ImageValidator::validate($path, 'image/png');
self::assertSame('image/png', $result->canonicalMime);
self::assertSame('png', $result->extension);
} finally {
unlink($path);
}
}
public function testItRejectsAFileWithTheWrongDeclaredMime(): void
{
$path = self::TMP_DIR . '/bf-bad-mime-' . bin2hex(random_bytes(4)) . '.png';
self::assertNotFalse(file_put_contents($path, self::validPng(8, 8)));
try {
$this->expectException(InvalidImageException::class);
$this->expectExceptionMessage('Declared MIME does not match file contents');
ImageValidator::validate($path, 'image/jpeg');
} finally {
unlink($path);
}
}
public function testItRejectsTextContent(): void
{
$path = self::TMP_DIR . '/bf-text-' . bin2hex(random_bytes(4)) . '.txt';
self::assertNotFalse(file_put_contents($path, 'this is not an image'));
try {
$this->expectException(InvalidImageException::class);
ImageValidator::validate($path, 'image/png');
} finally {
unlink($path);
}
}
public function testItRejectsOversizedFiles(): void
{
$path = self::TMP_DIR . '/bf-huge-' . bin2hex(random_bytes(4)) . '.png';
// Create a 2 MB + 1 byte file
$bytes = str_repeat('A', 2_000_001);
self::assertNotFalse(file_put_contents($path, $bytes));
try {
$this->expectException(InvalidImageException::class);
$this->expectExceptionMessage('exceeds the 2000000 byte limit');
ImageValidator::validate($path, 'image/png');
} finally {
unlink($path);
}
}
public function testItRejectsOversizedDimensions(): void
{
$path = self::TMP_DIR . '/bf-wide-' . bin2hex(random_bytes(4)) . '.png';
self::assertNotFalse(file_put_contents($path, self::validPng(600, 8)));
try {
$this->expectException(InvalidImageException::class);
$this->expectExceptionMessage('exceed the 512 pixel limit');
ImageValidator::validate($path, 'image/png');
} finally {
unlink($path);
}
}
public function testItAcceptsJpegWebpAndGif(): void
{
$jpeg = self::TMP_DIR . '/bf-jpeg-' . bin2hex(random_bytes(4)) . '.jpg';
$webp = self::TMP_DIR . '/bf-webp-' . bin2hex(random_bytes(4)) . '.webp';
$gif = self::TMP_DIR . '/bf-gif-' . bin2hex(random_bytes(4)) . '.gif';
file_put_contents($jpeg, self::validJpeg(8, 8));
file_put_contents($webp, self::validWebp(8, 8));
file_put_contents($gif, self::validGif(8, 8));
try {
self::assertSame('jpg', ImageValidator::validate($jpeg, 'image/jpeg')->extension);
self::assertSame('webp', ImageValidator::validate($webp, 'image/webp')->extension);
self::assertSame('gif', ImageValidator::validate($gif, 'image/gif')->extension);
} finally {
unlink($jpeg);
unlink($webp);
unlink($gif);
}
}
/**
* Build a minimal valid PNG of the given dimensions, using a pre-baked
* 8x8 transparent PNG as the base and trusting `getimagesize` to accept
* any correctly-formed PNG. We just need the file to (1) have the PNG
* magic and (2) be decodable by GD or `getimagesize`.
*
* For the oversized-dimensions test we synthesize a 600x8 PNG via
* `imagecreatetruecolor` + `imagepng` to force the IHDR to claim those
* dimensions.
*/
private static function validPng(int $width, int $height): string
{
if ($width === 8 && $height === 8) {
return base64_decode(
'iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAFElEQVR4nGNgYGD4z0AswK' .
'EWBgYGRgYGBkYGRgAAB4nCH2AAAAAElFTkSuQmCC',
true,
);
}
$im = imagecreatetruecolor($width, $height);
if ($im === false) {
throw new \RuntimeException('Failed to create image.');
}
ob_start();
imagepng($im);
$bytes = ob_get_clean();
imagedestroy($im);
return (string) $bytes;
}
private static function validJpeg(int $width, int $height): string
{
$im = imagecreatetruecolor($width, $height);
if ($im === false) {
throw new \RuntimeException('Failed to create image.');
}
ob_start();
imagejpeg($im);
$bytes = ob_get_clean();
imagedestroy($im);
return (string) $bytes;
}
private static function validWebp(int $width, int $height): string
{
$im = imagecreatetruecolor($width, $height);
if ($im === false) {
throw new \RuntimeException('Failed to create image.');
}
ob_start();
imagewebp($im);
$bytes = ob_get_clean();
imagedestroy($im);
return (string) $bytes;
}
private static function validGif(int $width, int $height): string
{
$im = imagecreatetruecolor($width, $height);
if ($im === false) {
throw new \RuntimeException('Failed to create image.');
}
ob_start();
imagegif($im);
$bytes = ob_get_clean();
imagedestroy($im);
return (string) $bytes;
}
}