Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Design/DiviCustomCssUpdateAbilityTest.php
T
Jan-Niclas LoosenandClaude Sonnet 5.5 ac333a164c feat(css): globales CSS bearbeiten (lit/divi-custom-css-get und -update)
WordPress „Zusätzliches CSS“ (in Divi „Eigenes CSS“) über die Design-Pipeline: Probelauf mit Zeilen-Diff,
design_rev, Bestätigung, Snapshot, Rollback und Wiederherstellen (Bereich custom_css). Der Guard lehnt </style,
<script, expression(, javascript: und neue @import ab und warnt bei fremden Hosts; nur Neues zählt, Bestehendes
blockiert keine Änderung. Erfordert edit_css und die Divi-Rolle Theme-Anpassung.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 16:36:16 +01:00

242 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\Design\CustomCssArea;
use LIT\MCP\Integrations\Divi\Design\CustomCssGuard;
use LIT\MCP\Integrations\Divi\Design\DesignChange;
use LIT\MCP\Integrations\Divi\Design\DiviCustomCssUpdateAbility;
use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Support\FakeCustomCssStore;
use LIT\MCP\Tests\Support\LockTable;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class DiviCustomCssUpdateAbilityTest extends TestCase
{
private FakeCustomCssStore $store;
protected function setUp(): void
{
parent::setUp();
LockTable::install();
Functions\when('current_user_can')->justReturn(true);
$this->store = new FakeCustomCssStore(".a { color: red; }\n.b { margin: 0; }");
}
private function ability(?SnapshotStore $snapshots = null, bool $customize = true): DiviCustomCssUpdateAbility
{
$options = Mockery::mock(ThemeOptionsAdapter::class);
$options->allows('canCustomize')->andReturn($customize);
return new DiviCustomCssUpdateAbility(
$this->store,
$options,
new DesignChange($snapshots ?? Mockery::mock(SnapshotStore::class), new FakeChangeLog(3)),
new CustomCssGuard('kunde.test')
);
}
/** A snapshot store that expects exactly one snapshot of the CSS as it is now. */
private function snapshots(): SnapshotStore
{
$snapshots = Mockery::mock(SnapshotStore::class);
$snapshots->expects('save')
->with('custom_css', ['stylesheet' => 'Divi', 'css' => $this->store->css], 'lit/divi-custom-css-update')
->andReturn('custom_css-1');
$snapshots->allows('attachLog');
return $snapshots;
}
/**
* @param array<string, mixed> $input
* @return array<string, mixed>
*/
private function dryRun(DiviCustomCssUpdateAbility $ability, array $input): array
{
return self::assertToolError($ability->execute($input), 'confirm_required');
}
public function testSchemaAndMeta(): void
{
$ability = $this->ability();
$properties = $ability->inputSchema()['properties'];
self::assertSame('lit/divi-custom-css-update', $ability->name());
self::assertFalse($ability->isDirect());
self::assertSame(Annotations::destructive(true), $ability->annotations());
self::assertSame([DiviFeature::ThemeOptionRead, DiviFeature::ThemeOptionWrite], $ability->diviFeatures([]));
self::assertSame(['css', 'append', 'replace', 'confirm', 'design_rev'], array_keys($properties));
self::assertSame(['old', 'new'], $properties['replace']['required']);
self::assertStringContainsString('confirm: true mit design_rev', $ability->description());
self::assertSame(['diff', 'design_rev'], array_slice($ability->outputSchema()['required'], -2));
}
/** Custom CSS needs WordPress' own right and Divi's role right, before anything is read. */
public function testNeedsEditCssAndTheCustomizerRight(): void
{
$input = ['append' => '.c{}'];
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_css');
self::assertToolError($this->ability()->execute($input), 'forbidden');
self::assertToolError($this->ability()->permission($input), 'forbidden');
Functions\when('current_user_can')->justReturn(true);
self::assertToolError($this->ability(customize: false)->execute($input), 'forbidden');
self::assertSame([], $this->store->writes);
}
public function testExactlyOneModeIsRequired(): void
{
foreach ([[], ['css' => 'a{}', 'append' => 'b{}'], ['append' => 'a{}', 'replace' => ['old' => 'a', 'new' => 'b']]] as $input) {
self::assertToolError($this->ability()->execute($input), 'validation_failed');
}
self::assertSame([], $this->store->writes);
}
public function testTheDryRunShowsDiffReachAndRevAndWritesNothing(): void
{
$dry = $this->dryRun($this->ability(), ['append' => '.c { padding: 0; }']);
self::assertSame([['action' => 'added', 'line' => '.c { padding: 0; }']], $dry['diff']);
self::assertContains(CustomCssArea::SITE_WIDE, $dry['warnings']);
self::assertNotSame('', $dry['design_rev']);
self::assertSame([], $this->store->writes);
}
public function testConfirmTakesASnapshotAndWritesTheNewText(): void
{
$before = $this->store->css;
$ability = $this->ability($this->snapshots());
$input = ['append' => '.c { padding: 0; }'];
$dry = $this->dryRun($ability, $input);
$result = $ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]);
self::assertTrue($result['ok'], (string) json_encode($result));
self::assertSame([$before . "\n.c { padding: 0; }"], $this->store->writes);
}
public function testAppendStartsOnALineOfItsOwn(): void
{
$this->store->css = ".a{}\n";
$dry = $this->dryRun($this->ability(), ['append' => '.b{}']);
self::assertSame([['action' => 'added', 'line' => '.b{}']], $dry['diff']);
$this->store->css = '';
$dry = $this->dryRun($this->ability(), ['append' => '.b{}']);
self::assertStringContainsString('1 Zeilen hinzugefügt, 0 entfernt (0 → 4 Bytes)', $dry['message']);
}
public function testReplaceNeedsOneUniqueMatch(): void
{
$this->store->css = ".a { color: red; }\n.b { color: red; }";
$ability = $this->ability();
$none = self::assertToolError($ability->execute(['replace' => ['old' => 'blue', 'new' => 'x']]), 'validation_failed');
$many = self::assertToolError($ability->execute(['replace' => ['old' => 'color: red', 'new' => 'x']]), 'validation_failed');
$one = $this->dryRun($ability, ['replace' => ['old' => '.a { color: red; }', 'new' => '.a { color: blue; }']]);
self::assertStringContainsString('nicht vor', $none['message']);
self::assertStringContainsString('2-mal', $many['message']);
self::assertSame(
[['action' => 'removed', 'line' => '.a { color: red; }'], ['action' => 'added', 'line' => '.a { color: blue; }']],
$one['diff']
);
self::assertSame([], $this->store->writes);
}
public function testReplaceNeedsOldAndNew(): void
{
foreach ([['old' => '', 'new' => 'x'], ['old' => 'a'], 'text', ['old' => 'a', 'new' => 5]] as $replace) {
self::assertToolError($this->ability()->execute(['replace' => $replace]), 'validation_failed');
}
}
public function testRefusedCssStoresNothingAndSaysWhy(): void
{
foreach (['</style><script>x</script>', "@import url('https://evil.test/a.css');", 'a{ width: expression(1) }'] as $css) {
$error = self::assertToolError($this->ability()->execute(['append' => $css, 'confirm' => true, 'design_rev' => 'x']), 'validation_failed');
self::assertStringContainsString('nicht gespeichert', $error['message'], $css);
}
self::assertSame([], $this->store->writes);
}
/** A site that already imports a font stylesheet stays editable; a new or changed import is refused. */
public function testAnImportThatIsAlreadyInTheCssDoesNotBlockAnEdit(): void
{
$this->store->css = '@import url("https://fonts.googleapis.com/css?family=Roboto");' . "\n.a { color: red; }";
$dry = $this->dryRun($this->ability(), ['append' => '.b { margin: 0; }']);
$error = self::assertToolError(
$this->ability()->execute(['replace' => ['old' => 'googleapis.com', 'new' => 'evil.test']]),
'validation_failed'
);
self::assertSame([['action' => 'added', 'line' => '.b { margin: 0; }']], $dry['diff']);
self::assertStringContainsString('@import ist nicht erlaubt', $error['message']);
}
public function testTheDryRunCarriesTheGuardsWarnings(): void
{
$dry = $this->dryRun($this->ability(), ['append' => 'body { display: none }']);
self::assertStringContainsString('blendet die Seite aus', implode(' ', $dry['warnings']));
}
public function testEmptyingNamesItInTheWarnings(): void
{
$dry = $this->dryRun($this->ability(), ['css' => '']);
self::assertContains('Das gesamte eigene CSS der Website wird geleert.', $dry['warnings']);
}
public function testTheSameTextIsNoChange(): void
{
$result = $this->ability()->execute(['css' => $this->store->css]);
self::assertTrue($result['ok']);
self::assertSame([], $result['diff']);
self::assertSame([], $this->store->writes);
}
public function testAStaleDesignRevWritesNothing(): void
{
// No snapshot expectation: the rev is checked before anything is saved.
$result = $this->ability()->execute(['append' => '.c{}', 'confirm' => true, 'design_rev' => 'veraltet']);
self::assertToolError($result, 'rev_mismatch');
self::assertSame([], $this->store->writes);
}
/** WordPress strips what an account without unfiltered_html may not store; the change must not go live altered. */
public function testATextThatIsStoredAlteredIsRolledBack(): void
{
$original = $this->store->css;
$this->store = new FakeCustomCssStore($original, 'Divi', static fn (string $css): string => strip_tags($css) . ' ');
$ability = $this->ability($this->snapshots());
$input = ['append' => '.c { padding: 0; }'];
$dry = $this->dryRun($ability, $input);
try {
$ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]);
self::fail('expected WriteFailed');
} catch (WriteFailed $e) {
self::assertTrue($e->rolledBack);
}
self::assertSame($original . ' ', $this->store->css, 'the original text was written back (the fake appends a space)');
self::assertCount(2, $this->store->writes);
self::assertSame($original, $this->store->writes[1]);
}
}