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>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
96fa58188b
commit
ac333a164c
@@ -0,0 +1,241 @@
|
||||
<?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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user