*/ private array $tempFiles = []; /** @var array URL => body the fake fetcher writes (null = download fails). */ private array $bodies = []; /** @var list */ private array $fetches = []; /** @var array 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 */ 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 { $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 $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 */ 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 */ 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'), 'fetch() skipped the refused URL'); $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] = '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 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(); } }