Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Design/DiviFontsUploadAbilityTest.php
T

539 lines
22 KiB
PHP

<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\Design\DiviFontsUploadAbility;
use LIT\MCP\Integrations\Divi\Design\FontCatalog;
use LIT\MCP\Integrations\Divi\Design\FontInstaller;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Media\RemoteFileFetcher;
use LIT\MCP\Limits;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Support\ReleaseMatrices;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use Mockery\MockInterface;
use RuntimeException;
final class DiviFontsUploadAbilityTest extends TestCase
{
private const WOFF2 = "wOF2\x00\x01\x00\x00woff2-body";
private const TTF = "\x00\x01\x00\x00\x00\x0cttf-body";
private const WOFF2_URL = 'https://fonts.example.com/inter.woff2';
private const TTF_URL = 'https://fonts.example.com/inter.ttf';
/** @var list<string> */
private array $tempFiles = [];
/** @var array<string, string|null> URL => body the fake fetcher writes (null = download fails). */
private array $bodies = [];
/** @var list<array{url: string, tmp: string, max: int, context: string}> */
private array $fetches = [];
/** @var array<string, string> Path => content of the temp files when install() ran. */
private array $installedFrom = [];
/** URL whose allows() turns false after the first call (it was valid on input, refused at download). */
private ?string $revokedUrl = null;
/** @var array<string, int> */
private array $allowCalls = [];
private string $errorLog = '';
private string|false $previousErrorLog = false;
protected function setUp(): void
{
parent::setUp();
$this->errorLog = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
$this->previousErrorLog = ini_set('error_log', $this->errorLog);
$this->bodies = [self::WOFF2_URL => self::WOFF2, self::TTF_URL => self::TTF];
Functions\when('current_user_can')->justReturn(true);
Functions\when('get_option')->justReturn(['upload_mb' => 1]);
Functions\when('wp_tempnam')->alias(function (): string {
$file = (string) tempnam(sys_get_temp_dir(), 'lit-font');
$this->tempFiles[] = $file;
return $file;
});
Functions\when('wp_delete_file')->alias(static function (string $file): void {
if (is_file($file)) {
unlink($file);
}
});
}
protected function tearDown(): void
{
ini_set('error_log', (string) $this->previousErrorLog);
foreach ([...$this->tempFiles, $this->errorLog] as $file) {
if (is_file($file)) {
unlink($file);
}
}
parent::tearDown();
}
private function fetcher(): RemoteFileFetcher
{
$test = $this;
return new class ($test) extends RemoteFileFetcher {
public function __construct(private DiviFontsUploadAbilityTest $test)
{
parent::__construct(static fn (string $host): array => ['93.184.216.34']);
}
public function allows(string $url): bool
{
return $this->test->allowed($url);
}
public function fetch(string $url, string $tmp, int $maxBytes, string $context): ?array
{
return $this->test->fakeFetch($url, $tmp, $maxBytes, $context);
}
};
}
/** The fake fetcher's allows(). */
public function allowed(string $url): bool
{
$this->allowCalls[$url] = ($this->allowCalls[$url] ?? 0) + 1;
if ($url === $this->revokedUrl && $this->allowCalls[$url] > 1) {
return false;
}
return str_starts_with($url, 'https://') && ! str_contains($url, 'intern');
}
/** The fake fetcher's download: writes the body of $url into $tmp. */
public function fakeFetch(string $url, string $tmp, int $maxBytes, string $context): ?array
{
if (! $this->allowed($url)) { // like the real fetch(): a refused URL is never requested
return null;
}
$this->fetches[] = ['url' => $url, 'tmp' => $tmp, 'max' => $maxBytes, 'context' => $context];
$body = $this->bodies[$url] ?? null;
if ($body === null) {
return null;
}
file_put_contents($tmp, substr($body, 0, $maxBytes + 1));
return ['response' => ['code' => 200], 'headers' => []];
}
/** @param list<string> $reserved */
private static function catalog(array $reserved = ['Open Sans', 'Arial'], bool $canManage = true): FontCatalog
{
$catalog = Mockery::mock(FontCatalog::class);
$catalog->allows('reserved')->andReturn($reserved);
$catalog->allows('canManage')->andReturn($canManage);
return $catalog;
}
private static function installer(bool $accepts = true): FontInstaller&MockInterface
{
$installer = Mockery::mock(FontInstaller::class);
$installer->allows('formats')->andReturn(['woff2', 'woff', 'ttf', 'otf']);
$installer->allows('nameMaxLength')->andReturn(100);
$installer->allows('accepts')->andReturn($accepts);
return $installer;
}
private function ability(
?FontInstaller $installer = null,
?FakeChangeLog $log = null,
?FontCatalog $catalog = null
): DiviFontsUploadAbility {
return new DiviFontsUploadAbility(
$catalog ?? self::catalog(),
$installer ?? self::installer(),
$this->fetcher(),
$log ?? FakeChangeLog::forbidden(),
new Limits()
);
}
/** @return array<string, mixed> */
private static function input(array $overrides = []): array
{
return array_merge([
'name' => 'Inter Display',
'files' => ['woff2' => self::WOFF2_URL, 'ttf' => self::TTF_URL],
'weights' => [700, 400],
], $overrides);
}
private function assertTempFilesRemoved(): void
{
self::assertNotSame([], $this->tempFiles, 'the files were downloaded');
foreach ($this->tempFiles as $file) {
self::assertFileDoesNotExist($file);
}
}
public function testIsAnIndirectAdditiveDiviToolForFontUpload(): void
{
$ability = $this->ability();
self::assertSame('lit/divi-fonts-upload', $ability->name());
self::assertFalse($ability->isDirect());
self::assertSame(['readonly' => false, 'destructive' => false, 'idempotent' => false], $ability->annotations());
self::assertSame([DiviFeature::FontUpload], $ability->diviFeatures([]));
self::assertSame(['name', 'files', 'weights'], $ability->inputSchema()['required']);
self::assertFalse($ability->inputSchema()['properties']['files']['additionalProperties']);
}
public function testPermissionAndExecuteAskTheFontRights(): void
{
$ability = $this->ability(catalog: self::catalog(canManage: false));
self::assertToolError($ability->permission(self::input()), 'forbidden');
self::assertToolError($ability->execute(self::input()), 'forbidden');
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'upload_files');
self::assertToolError($this->ability()->permission(self::input()), 'forbidden');
self::assertToolError($this->ability()->execute(self::input()), 'forbidden');
self::assertSame([], $this->fetches, 'nothing is downloaded without the rights');
}
public function testEveryInputProblemIsReportedAtOnceWithItsPath(): void
{
$result = $this->ability()->execute([
'name' => 'Inter (Pro)',
'files' => ['woff2' => 'http://intern.example.com/a.woff2', 'eot' => self::TTF_URL, 'ttf' => 5],
'weights' => [400, 450, 400],
'generic_family' => 'serifs',
'confirm' => 'yes',
'style' => 'italic',
]);
$fields = self::errorFields($result);
self::assertToolError($result, 'validation_failed');
self::assertStringContainsString('„Inter Pro“', $fields['name']);
self::assertStringContainsString('öffentlich', $fields['files.woff2']);
self::assertStringContainsString('erlaubt: woff2, woff, ttf, otf', $fields['files.eot']);
self::assertSame('URL als Text erwartet', $fields['files.ttf']);
self::assertStringContainsString('100, 200, 300, 400, 500, 600, 700, 800, 900', $fields['weights']);
self::assertStringContainsString('sans-serif', $fields['generic_family']);
self::assertSame('true oder false', $fields['confirm']);
self::assertSame('unbekanntes Feld', $fields['style']);
self::assertSame([], $this->fetches);
}
/** @return array<string, array{bool}> */
public static function confirmModes(): array
{
return ['dry run' => [false], 'confirm' => [true]];
}
/** @dataProvider confirmModes */
public function testNothingIsFetchedWhenOneUrlIsRefusedByAllows(bool $confirm): void
{
$result = $this->ability(self::installer(), null)->execute(self::input([
'files' => ['woff2' => self::WOFF2_URL, 'ttf' => 'https://intern.example.com/inter.ttf'],
'confirm' => $confirm,
]));
self::assertToolError($result, 'validation_failed');
self::assertSame([], $this->fetches, 'the valid URL is not fetched either');
}
/** @dataProvider confirmModes */
public function testFetchIsNeverCalledForAUrlAllowsRefusesAtDownloadTime(bool $confirm): void
{
$this->revokedUrl = self::TTF_URL;
$installer = self::installer();
$installer->expects('install')->never();
$result = $this->ability($installer)->execute(self::input(['confirm' => $confirm]));
self::assertToolError($result, 'font_fetch_failed');
self::assertSame(['files.ttf' => 'nicht abrufbar'], self::errorFields($result));
self::assertSame([self::WOFF2_URL], array_column($this->fetches, 'url'), 'the refused URL was never requested');
$this->assertTempFilesRemoved();
}
public function testFilesAndWeightsNeedTheirShape(): void
{
$fields = self::errorFields($this->ability()->execute(self::input(['files' => [], 'weights' => []])));
self::assertSame('1 bis 4 Formate als Objekt {Format: URL}', $fields['files']);
self::assertStringContainsString('"all" oder Liste', $fields['weights']);
$fields = self::errorFields($this->ability()->execute(self::input([
'files' => [self::WOFF2_URL],
'weights' => 'bold',
])));
self::assertSame('1 bis 4 Formate als Objekt {Format: URL}', $fields['files']);
self::assertStringContainsString('"all" oder Liste', $fields['weights']);
$fields = self::errorFields($this->ability()->execute(['files' => ['woff2' => self::WOFF2_URL]]));
self::assertStringContainsString('Name fehlt', $fields['name']);
self::assertStringContainsString('"all" oder Liste', $fields['weights']);
}
public function testAFormatDiviDoesNotKnowIsRefused(): void
{
$installer = Mockery::mock(FontInstaller::class);
$installer->allows('formats')->andReturn(['ttf', 'otf']);
$installer->allows('nameMaxLength')->andReturn(100);
$fields = self::errorFields($this->ability($installer)->execute(self::input()));
self::assertStringContainsString('unterstützt Divi auf dieser Website nicht', $fields['files.woff2']);
self::assertArrayNotHasKey('files.ttf', $fields);
}
public function testANameThatIsTakenWhateverTheCaseIsAConflict(): void
{
$error = self::assertToolError(
$this->ability(catalog: self::catalog(['Open Sans', 'Inter Display']))->execute(
self::input(['name' => 'inter display'])
),
'conflict'
);
self::assertStringContainsString('„Inter Display“', $error['message']);
self::assertSame([], $this->fetches);
}
public function testTheDryRunDownloadsAndChecksEveryFileAndAsksForConfirmation(): void
{
$installer = self::installer();
$installer->expects('install')->never();
$error = self::assertToolError($this->ability($installer)->execute(self::input()), 'confirm_required');
self::assertStringContainsString('„Inter Display“', $error['message']);
self::assertStringContainsString('ganzen Website', $error['message']);
self::assertStringContainsString('Nichts wurde gespeichert (Probelauf).', $error['message']);
self::assertSame('Inter Display', $error['font']);
self::assertSame([
['format' => 'woff2', 'bytes' => strlen(self::WOFF2), 'sha256' => hash('sha256', self::WOFF2)],
['format' => 'ttf', 'bytes' => strlen(self::TTF), 'sha256' => hash('sha256', self::TTF)],
], $error['files']);
self::assertSame('400,700', $error['weights']);
self::assertSame('sans-serif', $error['generic_family']);
self::assertSame([self::WOFF2_URL, self::TTF_URL], array_column($this->fetches, 'url'));
self::assertSame(['font upload', 'font upload'], array_column($this->fetches, 'context'));
self::assertSame([1048576, 1048576], array_column($this->fetches, 'max'), 'upload limit 1 MB');
$this->assertTempFilesRemoved();
}
public function testTheSizeCapIsFiveMegabytesEvenWithAHigherUploadLimit(): void
{
Functions\when('get_option')->justReturn(['upload_mb' => 32]);
$this->ability()->execute(self::input());
self::assertSame([5242880, 5242880], array_column($this->fetches, 'max'));
}
public function testAFileAboveTheCapIsInvalid(): void
{
$this->bodies[self::TTF_URL] = self::TTF . str_repeat('x', 1048576);
$result = $this->ability()->execute(self::input());
self::assertToolError($result, 'font_invalid');
self::assertSame('größer als 1 MB', self::errorFields($result)['files.ttf']);
self::assertArrayNotHasKey('files.woff2', self::errorFields($result));
$this->assertTempFilesRemoved();
}
public function testTheContentMustBeTheFormatOfItsKey(): void
{
$this->bodies[self::WOFF2_URL] = self::TTF;
$this->bodies[self::TTF_URL] = '<?php evil();';
$result = $this->ability()->execute(self::input());
$fields = self::errorFields($result);
self::assertToolError($result, 'font_invalid');
self::assertSame('Inhalt ist eine ttf-Datei, keine woff2-Datei', $fields['files.woff2']);
self::assertSame('Inhalt ist keine Schriftdatei', $fields['files.ttf']);
$this->assertTempFilesRemoved();
}
public function testAnEmptyFileIsInvalid(): void
{
$this->bodies[self::TTF_URL] = '';
$result = $this->ability()->execute(self::input());
self::assertToolError($result, 'font_invalid');
self::assertSame('leere Datei', self::errorFields($result)['files.ttf']);
}
public function testAFileDivisContentCheckRefusesIsInvalid(): void
{
$result = $this->ability(self::installer(accepts: false))->execute(self::input());
self::assertToolError($result, 'font_invalid');
self::assertSame('von Divi nicht als Schriftdatei erkannt', self::errorFields($result)['files.woff2']);
$this->assertTempFilesRemoved();
}
public function testAFailedDownloadIsFontFetchFailedWithoutAnOracle(): void
{
$this->bodies[self::TTF_URL] = null;
$this->bodies[self::WOFF2_URL] = self::TTF;
$result = $this->ability()->execute(self::input());
$error = self::assertToolError($result, 'font_fetch_failed');
self::assertSame(
['files.woff2' => 'Inhalt ist eine ttf-Datei, keine woff2-Datei', 'files.ttf' => 'nicht abrufbar'],
self::errorFields($result)
);
self::assertStringContainsString('nicht geladen', $error['message']);
$this->assertTempFilesRemoved();
}
public function testConfirmInstallsFromTheCheckedTempFilesAndLogsTheChecksums(): void
{
$installer = self::installer();
$installer->expects('install')->once()
->with('Inter Display', Mockery::type('array'), '400,700', 'serif')
->andReturnUsing(function (string $name, array $files): array {
$this->installedFrom = array_map('file_get_contents', $files);
return array_keys($files);
});
$log = new FakeChangeLog(7);
$result = $this->ability($installer, $log)->execute(
self::input(['generic_family' => 'serif', 'confirm' => true])
);
self::assertSame(['woff2' => self::WOFF2, 'ttf' => self::TTF], $this->installedFrom);
self::assertTrue($result['ok']);
self::assertSame('Inter Display', $result['font']);
self::assertSame(['woff2', 'ttf'], array_column($result['files'], 'format'));
self::assertSame(7, $result['log_id']);
self::assertSame([['id' => 'Inter Display', 'type' => 'divi_font', 'action' => 'created',
'label' => 'Inter Display']], $result['changed']);
self::assertCount(1, $log->records);
self::assertSame('lit/divi-fonts-upload', $log->records[0]['ability']);
self::assertSame('divi_font', $log->records[0]['object_type']);
self::assertSame('Inter Display', $log->records[0]['object_id']);
self::assertStringContainsString('woff2 ' . hash('sha256', self::WOFF2), $log->records[0]['summary']);
self::assertStringContainsString('ttf ' . hash('sha256', self::TTF), $log->records[0]['summary']);
self::assertSame(2, count($this->fetches), 'downloaded and checked again for the install');
$this->assertTempFilesRemoved();
}
public function testWeightsAllArePassedOn(): void
{
$installer = self::installer();
$installer->expects('install')->once()->with('Inter Display', Mockery::type('array'), 'all', 'sans-serif')
->andReturn(['woff2', 'ttf']);
$result = $this->ability($installer, new FakeChangeLog())->execute(
self::input(['weights' => 'all', 'confirm' => true])
);
self::assertSame('all', $result['weights']);
}
public function testAPartialInstallIsRemovedAgain(): void
{
$installer = self::installer();
$installer->allows('install')->andReturn(['woff2']);
$installer->expects('remove')->once()->with('Inter Display');
try {
$this->ability($installer)->execute(self::input(['confirm' => true]));
self::fail('WriteFailed expected');
} catch (WriteFailed $e) {
self::assertTrue($e->rolledBack);
$error = self::assertToolError($e->toError(), 'write_failed');
self::assertStringContainsString('ttf', $error['message']);
}
$this->assertTempFilesRemoved();
}
public function testAnInstallThatStoredNothingIsACleanRollback(): void
{
$installer = self::installer();
$installer->allows('install')->andReturn([]);
$installer->expects('remove')->never();
$log = FakeChangeLog::partialsOnly();
try {
$this->ability($installer, $log)->execute(self::input(['confirm' => true]));
self::fail('WriteFailed expected');
} catch (WriteFailed $e) {
self::assertTrue($e->rolledBack);
$error = self::assertToolError($e->toError(), 'write_failed');
self::assertStringContainsString('nichts gespeichert', $error['message']);
}
self::assertSame([], $log->partials);
}
public function testAPartialInstallThatCannotBeRemovedIsAPartialWrite(): void
{
$installer = self::installer();
$installer->allows('install')->andReturn(['woff2']);
$installer->allows('remove')->andThrow(new RuntimeException('font_remove refused: Font Does not Exist.'));
$log = FakeChangeLog::partialsOnly();
try {
$this->ability($installer, $log)->execute(self::input(['confirm' => true]));
self::fail('WriteFailed expected');
} catch (WriteFailed $e) {
self::assertFalse($e->rolledBack);
$error = self::assertToolError($e->toError(), 'partial_write');
self::assertStringContainsString('„Inter Display“', $error['message']);
}
self::assertCount(1, $log->partials);
self::assertSame('divi_font', $log->partials[0]['object_type']);
}
public function testARefusedInstallIsAWriteFailureThatLeavesNothing(): void
{
$installer = self::installer();
$installer->allows('install')->andThrow(new RuntimeException('font_add refused: kaputt'));
$installer->expects('remove')->never();
try {
$this->ability($installer)->execute(self::input(['confirm' => true]));
self::fail('WriteFailed expected');
} catch (WriteFailed $e) {
self::assertTrue($e->rolledBack);
self::assertStringNotContainsString('kaputt', Errors::clientMessage($e->toError()));
}
$this->assertTempFilesRemoved();
}
public function testALockFromTheInstallerPropagatesInsteadOfBecomingAWriteFailure(): void
{
$locked = new DiviLocked(
ReleaseMatrices::uniform(ReleaseState::Incompatible, '5.3.0', ['x']),
[DiviFeature::FontUpload]
);
$installer = self::installer();
$installer->allows('install')->andThrow($locked);
$installer->expects('remove')->never();
try {
$this->ability($installer)->execute(self::input(['confirm' => true]));
self::fail('DiviLocked expected');
} catch (DiviLocked $e) {
self::assertSame($locked, $e);
}
$this->assertTempFilesRemoved();
}
}