feat: lit/divi-fonts-upload – eigene Schriften von URLs hochladen (Probelauf mit SHA-256, confirm, Rücknahme bei Teilerfolg)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-19 18:04:55 +02:00
co-authored by Claude Sonnet 5.5
parent ad76d5ae66
commit 35e92fcb6c
2 changed files with 944 additions and 0 deletions
@@ -0,0 +1,449 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\Rollback;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use LIT\MCP\Integrations\Media\AdminIncludes;
use LIT\MCP\Integrations\Media\RemoteFileFetcher;
use LIT\MCP\Limits;
use RuntimeException;
use WP_Error;
defined('ABSPATH') || exit;
/**
* lit/divi-fonts-upload (spec §8.2, rulings W8–W10): adds a custom font to Divi's uploaded fonts from public URLs,
* one file per format. No pipeline and no snapshot (A2), but its own dry run: without confirm the files are really
* downloaded (RemoteFileFetcher, at most 5 MB and the upload limit each), checked by magic bytes against their
* format key (FontSignature) and by Divi's content check, the temp files deleted again, and the answer is
* confirm_required with format, size and SHA-256 of every file. confirm: true downloads and checks again and
* installs (FontInstaller); Divi's partial success is undone with font_remove. Every path deletes the temp files.
*/
final class DiviFontsUploadAbility implements Ability, RequiresDiviFeatures
{
/** Ruling A9: at most 5 MB per font file, whatever the upload limit allows. */
public const MAX_FILE_BYTES = 5 * 1024 * 1024;
public const OBJECT_TYPE = 'divi_font';
public const FAMILIES = ['serif', 'sans-serif', 'monospace', 'cursive', 'fantasy'];
public const WEIGHTS = [100, 200, 300, 400, 500, 600, 700, 800, 900];
private const KEYS = ['name', 'files', 'weights', 'generic_family', 'confirm'];
private const DEFAULT_FAMILY = 'sans-serif';
private const REASON = 'Divi hat die Schrift nicht vollständig übernommen.';
private const FILE_RULE = '1 bis 4 Formate als Objekt {Format: URL}';
public function __construct(
private FontCatalog $catalog,
private FontInstaller $installer,
private RemoteFileFetcher $fetcher,
private ChangeLog $log,
private Limits $limits
) {
}
public function name(): string
{
return 'lit/divi-fonts-upload';
}
public function label(): string
{
return 'Eigene Schrift in Divi hochladen';
}
public function description(): string
{
return 'Lädt eine eigene Schrift von öffentlichen URLs in Divis hochgeladene Schriften: files mit je einer '
. 'Datei pro Format (woff2, woff, ttf, otf; höchstens 5 MB je Datei). name: 1–100 Zeichen aus Buchstaben, '
. 'Ziffern, Leerzeichen, _ und -, kein Name einer Google-, Web-Safe- oder schon hochgeladenen Schrift. '
. 'weights: "all" oder Liste aus 100 … 900; generic_family: serif, sans-serif (Standard), monospace, '
. 'cursive oder fantasy. Wirkt auf die ganze Website: zuerst Probelauf (lädt und prüft die Dateien, nennt '
. 'Format, Größe und SHA-256), nach Rückfrage beim Nutzer confirm: true. Kein Snapshot; entfernen lässt '
. 'sich die Schrift im Divi-Builder.';
}
public function inputSchema(): array
{
$url = ['type' => 'string', 'description' => 'Öffentliche http(s)-URL der Datei.'];
return [
'type' => 'object',
'properties' => [
'name' => ['type' => 'string', 'description' => 'Name der Schrift (Familienname in Divi).'],
'files' => [
'type' => 'object',
'properties' => array_fill_keys(FontInstaller::FORMATS, $url),
'additionalProperties' => false,
],
'weights' => [
'type' => ['string', 'array'],
'items' => ['type' => 'integer'],
'description' => '"all" oder Liste der Schriftstärken aus 100, 200, … 900.',
],
'generic_family' => ['type' => 'string', 'enum' => self::FAMILIES],
'confirm' => ['type' => 'boolean'],
],
'required' => ['name', 'files', 'weights'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return ToolResult::schema([
'font' => ['type' => 'string'],
'files' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'format' => ['type' => 'string'],
'bytes' => ['type' => 'integer'],
'sha256' => ['type' => 'string'],
],
],
],
'weights' => ['type' => 'string'],
'generic_family' => ['type' => 'string'],
], ['font', 'files', 'weights', 'generic_family']);
}
public function annotations(): array
{
return Annotations::write(false);
}
public function isDirect(): bool
{
return false;
}
/** FontInstaller and the font lists of FontCatalog (name collision) both serve font-upload (ruling W1). */
public function diviFeatures(array $input): array
{
return [DiviFeature::FontUpload];
}
public function permission(array $input): bool|WP_Error
{
return DesignRights::fonts($this->catalog) ?? true;
}
/** @throws WriteFailed */
public function execute(array $input): mixed
{
$refusal = DesignRights::fonts($this->catalog);
if ($refusal !== null) {
return $refusal;
}
$errors = [];
foreach (array_diff(array_map('strval', array_keys($input)), self::KEYS) as $key) {
$errors[$key] = 'unbekanntes Feld';
}
$name = is_string($input['name'] ?? null) ? $input['name'] : '';
$taken = FontNameRules::taken($name, $this->catalog->reserved());
$nameProblem = $taken === null
? FontNameRules::check($name, [], $this->installer->nameMaxLength())
: null;
if ($nameProblem !== null) {
$errors['name'] = $nameProblem;
}
$urls = $this->urls($input['files'] ?? null, $errors);
$weights = self::weights($input['weights'] ?? null, $errors);
$family = $input['generic_family'] ?? self::DEFAULT_FAMILY;
if (! in_array($family, self::FAMILIES, true)) {
$errors['generic_family'] = 'eine von: ' . implode(', ', self::FAMILIES);
}
$confirm = $input['confirm'] ?? false;
if (! is_bool($confirm)) {
$errors['confirm'] = 'true oder false';
}
if ($errors !== []) {
return Errors::invalidInput('Ungültige Angaben zur Schrift; nichts wurde gespeichert.', $errors);
}
if ($taken !== null) {
return Errors::make(
'conflict',
sprintf('Eine Schrift „%s“ gibt es schon; nichts wurde gespeichert.', $taken),
'Einen anderen Namen wählen (Groß-/Kleinschreibung zählt nicht) oder die vorhandene Schrift verwenden.',
409
);
}
return $this->upload($name, $urls, $weights, (string) $family, $confirm === true);
}
/**
* @param array<string, mixed> $errors
* @return array<string, string> Format => URL, in the order of the input.
*/
private function urls(mixed $files, array &$errors): array
{
if (! is_array($files) || $files === [] || array_is_list($files) || count($files) > 4) {
$errors['files'] = self::FILE_RULE;
return [];
}
$known = $this->installer->formats();
$urls = [];
foreach ($files as $format => $url) {
$field = 'files.' . $format;
if (! in_array($format, FontInstaller::FORMATS, true)) {
$errors[$field] = 'unbekanntes Format; erlaubt: ' . implode(', ', FontInstaller::FORMATS);
} elseif (! in_array($format, $known, true)) {
$errors[$field] = 'dieses Format unterstützt Divi auf dieser Website nicht';
} elseif (! is_string($url) || $url === '') {
$errors[$field] = 'URL als Text erwartet';
} elseif (! $this->fetcher->allows($url)) {
$errors[$field] = 'nur öffentlich erreichbare http(s)-Adressen';
} else {
$urls[$format] = $url;
}
}
return $urls;
}
/**
* @param array<string, mixed> $errors
* @return string "all" or the sorted list as Divi stores it ("400,700").
*/
private static function weights(mixed $weights, array &$errors): string
{
if ($weights === 'all') {
return 'all';
}
if (
! is_array($weights) || $weights === [] || ! array_is_list($weights)
|| array_filter($weights, 'is_int') !== $weights
|| array_diff($weights, self::WEIGHTS) !== [] || count(array_unique($weights)) !== count($weights)
) {
$errors['weights'] = '"all" oder Liste verschiedener Schriftstärken aus '
. implode(', ', self::WEIGHTS);
return '';
}
sort($weights);
return implode(',', $weights);
}
/**
* Downloads and checks every file into temp files – for the dry run and again for the install – and deletes
* them on every path.
*
* @param array<string, string> $urls
* @return array<string, mixed>|WP_Error
* @throws WriteFailed
*/
private function upload(string $name, array $urls, string $weights, string $family, bool $confirm): array|WP_Error
{
AdminIncludes::load();
$temps = [];
try {
$files = $this->download($name, $urls, $temps);
if ($files instanceof WP_Error) {
return $files;
}
$report = array_values(array_map(
static fn (array $file): array => array_diff_key($file, ['path' => true]),
$files
));
if (! $confirm) {
return Errors::confirmRequired(
sprintf(
'Schrift „%s“ hochladen (%s): Sie steht danach auf der ganzen Website in Divi zur Auswahl '
. '(Builder, Theme-Optionen, Schrift-Variablen). Nichts wurde gespeichert (Probelauf).',
$name,
implode(', ', array_keys($files))
),
['font' => $name, 'files' => $report, 'weights' => $weights, 'generic_family' => $family]
);
}
$paths = array_map(static fn (array $file): string => $file['path'], $files);
$this->install($name, $paths, $weights, $family);
} finally {
foreach ($temps as $tmp) {
if (file_exists($tmp)) {
wp_delete_file($tmp);
}
}
}
$summary = sprintf('Schrift „%s“ hochgeladen (%s).', $name, implode(', ', array_map(
static fn (array $file): string => $file['format'] . ' ' . $file['sha256'],
$report
)));
$logId = $this->log->record($this->name(), self::OBJECT_TYPE, $name, $summary);
return ToolResult::ok(sprintf(
'Schrift „%s“ hochgeladen; sie steht jetzt in Divi zur Auswahl (z. B. für lit/divi-theme-options-update).',
$name
))
->changed($name, self::OBJECT_TYPE, 'created', $name)
->logId($logId)
->with('font', $name)
->with('files', $report)
->with('weights', $weights)
->with('generic_family', $family)
->toArray();
}
/**
* @param array<string, string> $urls
* @param list<string> $temps Gets every temp file created (the caller deletes them).
* @return array<string, array{format: string, bytes: int, sha256: string, path: string}>|WP_Error
*/
private function download(string $name, array $urls, array &$temps): array|WP_Error
{
$max = min($this->limits->uploadBytes(), self::MAX_FILE_BYTES);
$files = [];
$problems = [];
$unreachable = false;
foreach ($urls as $format => $url) {
// fetch() does not check the URL itself: a refused URL is never requested (same answer as a failed one).
if (! $this->fetcher->allows($url)) {
$problems['files.' . $format] = 'nicht abrufbar';
$unreachable = true;
continue;
}
$tmp = (string) wp_tempnam('lit-mcp-font');
if ($tmp === '') {
return Errors::writeFailed('Die temporäre Datei konnte nicht angelegt werden.');
}
$temps[] = $tmp;
if ($this->fetcher->fetch($url, $tmp, $max, 'font upload') === null) {
$problems['files.' . $format] = 'nicht abrufbar';
$unreachable = true;
continue;
}
$problem = $this->problemOf($tmp, $format, $name . '.' . $format, $max);
if ($problem !== null) {
$problems['files.' . $format] = $problem;
continue;
}
$files[$format] = [
'format' => $format,
'bytes' => (int) filesize($tmp),
'sha256' => (string) hash_file('sha256', $tmp),
'path' => $tmp,
];
}
if ($unreachable) {
return Errors::make(
'font_fetch_failed',
'Mindestens eine Schriftdatei konnte nicht geladen werden; nichts wurde gespeichert.',
'URLs prüfen; nur öffentlich erreichbare Dateien lassen sich laden.',
400,
['fields' => $problems]
);
}
return $problems === [] ? $files : Errors::make(
'font_invalid',
'Mindestens eine Datei ist keine gültige Schriftdatei ihres Formats; nichts wurde gespeichert.',
sprintf(
'Je Format die passende Datei angeben (woff2, woff, ttf oder otf, höchstens %d MB je Datei).',
intdiv($max, 1024 * 1024)
),
400,
['fields' => $problems]
);
}
/** The problem of a downloaded file (size, magic bytes, Divi's content check), null when it is fine. */
private function problemOf(string $tmp, string $format, string $filename, int $max): ?string
{
clearstatcache(true, $tmp);
$bytes = (int) filesize($tmp);
if ($bytes === 0) {
return 'leere Datei';
}
if ($bytes > $max) {
return sprintf('größer als %d MB', intdiv($max, 1024 * 1024));
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local temp file.
$header = (string) file_get_contents($tmp, false, null, 0, FontSignature::HEADER_BYTES);
$detected = FontSignature::detect($header);
if ($detected === null) {
return 'Inhalt ist keine Schriftdatei';
}
if ($detected !== $format) {
return sprintf('Inhalt ist eine %s-Datei, keine %s-Datei', $detected, $format);
}
return $this->installer->accepts($tmp, $filename) ? null : 'von Divi nicht als Schriftdatei erkannt';
}
/**
* @param array<string, string> $paths Format => checked temp file.
* @throws WriteFailed When Divi refuses the font or stores only some files (removed again if possible).
*/
private function install(string $name, array $paths, string $weights, string $family): void
{
try {
$installed = $this->installer->install($name, $paths, $weights, $family);
} catch (RuntimeException $e) {
// FontInstaller stores nothing when it throws.
throw WriteFailed::rolledBack(self::REASON, $e, 'font upload: ' . $e->getMessage());
}
$missing = array_values(array_diff(array_keys($paths), $installed));
if ($missing === []) {
return;
}
$detail = sprintf(
'font upload: Divi stored %s of %s',
implode(',', $installed),
implode(',', array_keys($paths))
);
$failures = Rollback::attempt(fn () => $this->installer->remove($name));
if ($failures === []) {
throw WriteFailed::rolledBack(
self::REASON . sprintf(' Divi hat %s nicht gespeichert.', implode(', ', $missing)),
detail: $detail
);
}
$this->log->recordPartial(
$this->name(),
self::OBJECT_TYPE,
$name,
sprintf('Schrift „%s“ nur teilweise hochgeladen, nicht entfernt (%s)', $name, $failures[0])
);
throw WriteFailed::partial(
self::REASON,
detail: $detail . '; remove failed: ' . $failures[0],
state: sprintf('Die Schrift „%s“ ist ohne %s in Divi gespeichert.', $name, implode(', ', $missing)),
hint: 'Den Aufruf nicht wiederholen; die Schrift im Divi-Builder (Schriften verwalten) prüfen und ggf. '
. 'löschen und dem Nutzer Bescheid geben.'
);
}
}
@@ -0,0 +1,495 @@
<?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\Media\RemoteFileFetcher;
use LIT\MCP\Limits;
use LIT\MCP\Tests\Support\FakeChangeLog;
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
{
$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'), '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] = '<?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 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();
}
}