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:
co-authored by
Claude Sonnet 5.5
parent
ad76d5ae66
commit
35e92fcb6c
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user