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,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\Design\CustomCssArea;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||||
use LIT\MCP\Tests\Support\FakeCustomCssStore;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use RuntimeException;
|
||||
|
||||
final class CustomCssAreaTest extends TestCase
|
||||
{
|
||||
public function testNamesTheAreaAndTheDiviFeatureItNeeds(): void
|
||||
{
|
||||
$area = new CustomCssArea(new FakeCustomCssStore());
|
||||
|
||||
self::assertSame('custom_css', $area->name());
|
||||
self::assertSame('Eigenes CSS', $area->label());
|
||||
self::assertSame(DiviFeature::ThemeOptionWrite, $area->feature());
|
||||
}
|
||||
|
||||
public function testRawIsTheStoredTextWithItsTheme(): void
|
||||
{
|
||||
$area = new CustomCssArea(new FakeCustomCssStore('.x{}', 'Child'));
|
||||
|
||||
self::assertSame(['stylesheet' => 'Child', 'css' => '.x{}'], $area->raw());
|
||||
}
|
||||
|
||||
public function testTheRevCoversTextAndTheme(): void
|
||||
{
|
||||
$area = new CustomCssArea(new FakeCustomCssStore());
|
||||
$rev = $area->rev(['stylesheet' => 'Divi', 'css' => '.a{}']);
|
||||
|
||||
self::assertSame($rev, $area->rev(['stylesheet' => 'Divi', 'css' => '.a{}']));
|
||||
self::assertNotSame($rev, $area->rev(['stylesheet' => 'Divi', 'css' => '.b{}']));
|
||||
self::assertNotSame($rev, $area->rev(['stylesheet' => 'Child', 'css' => '.a{}']), 'a theme switch is a change');
|
||||
}
|
||||
|
||||
public function testWriteReplacesTheTextAndReturnsWhatWasReadBack(): void
|
||||
{
|
||||
$store = new FakeCustomCssStore('.old{}');
|
||||
$area = new CustomCssArea($store);
|
||||
|
||||
$back = $area->write(['stylesheet' => 'Divi', 'css' => '.new{}'], false);
|
||||
|
||||
self::assertSame(['.new{}'], $store->writes);
|
||||
self::assertSame(['stylesheet' => 'Divi', 'css' => '.new{}'], $back);
|
||||
}
|
||||
|
||||
public function testRestoreWritesTheSnapshotTextBack(): void
|
||||
{
|
||||
$store = new FakeCustomCssStore('.changed{}');
|
||||
|
||||
$back = (new CustomCssArea($store))->restore(['stylesheet' => 'Divi', 'css' => '.old{}']);
|
||||
|
||||
self::assertSame('.old{}', $back['css']);
|
||||
self::assertSame(['.old{}'], $store->writes);
|
||||
}
|
||||
|
||||
public function testAFailingWriteThrows(): void
|
||||
{
|
||||
$this->expectException(RuntimeException::class);
|
||||
|
||||
(new CustomCssArea(new FakeCustomCssStore(refuse: true)))->write(['css' => '.a{}'], false);
|
||||
}
|
||||
|
||||
public function testVerifyInsistsOnTheExactText(): void
|
||||
{
|
||||
$area = new CustomCssArea(new FakeCustomCssStore());
|
||||
|
||||
self::assertTrue($area->verify(['css' => '.a{}'], ['stylesheet' => 'Divi', 'css' => '.a{}']));
|
||||
self::assertFalse($area->verify(['css' => '.a>b{}'], ['css' => '.a>b{}']), 'a stripped or escaped text is no success');
|
||||
self::assertSame([], $area->differences(['css' => 'a'], ['css' => 'b']));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\Design\CustomCssDiff;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class CustomCssDiffTest extends TestCase
|
||||
{
|
||||
public function testNamesRemovedAndAddedLines(): void
|
||||
{
|
||||
$rows = CustomCssDiff::between(".a { color: red; }\n.b { margin: 0; }", ".a { color: red; }\n.c { margin: 1px; }");
|
||||
|
||||
self::assertSame([
|
||||
['action' => 'removed', 'line' => '.b { margin: 0; }'],
|
||||
['action' => 'added', 'line' => '.c { margin: 1px; }'],
|
||||
], $rows);
|
||||
}
|
||||
|
||||
public function testEqualTextsHaveNoDiff(): void
|
||||
{
|
||||
self::assertSame([], CustomCssDiff::between(".a{}\n.b{}", ".a{}\n.b{}"));
|
||||
self::assertSame([], CustomCssDiff::between('', ''));
|
||||
}
|
||||
|
||||
public function testTheNewlineEndingTheLastLineIsNoLineOfItsOwn(): void
|
||||
{
|
||||
self::assertSame([['action' => 'added', 'line' => '.b{}']], CustomCssDiff::between(".a{}\n", ".a{}\n.b{}"));
|
||||
self::assertSame('changed', CustomCssDiff::between(".a{}", ".a{}\n")[0]['action']);
|
||||
}
|
||||
|
||||
public function testRepeatedLinesCountAsOften(): void
|
||||
{
|
||||
$rows = CustomCssDiff::between("x\nx", "x\nx\nx");
|
||||
|
||||
self::assertSame([['action' => 'added', 'line' => 'x']], $rows);
|
||||
}
|
||||
|
||||
public function testAChangeWithoutAnyDifferingLineIsStillShown(): void
|
||||
{
|
||||
self::assertSame('changed', CustomCssDiff::between("a\nb", "b\na")[0]['action']);
|
||||
self::assertSame('changed', CustomCssDiff::between("a\r\nb", "a\nb")[0]['action']);
|
||||
}
|
||||
|
||||
public function testCutsLongLinesAndLongDiffs(): void
|
||||
{
|
||||
$long = CustomCssDiff::between('', str_repeat('x', 300))[0]['line'];
|
||||
$rows = CustomCssDiff::between('', implode("\n", array_map(static fn (int $i): string => "l$i", range(1, 150))));
|
||||
|
||||
self::assertSame(201, mb_strlen($long));
|
||||
self::assertCount(101, $rows);
|
||||
self::assertSame(['action' => 'more', 'line' => '… und 50 weitere Zeilen'], $rows[100]);
|
||||
}
|
||||
|
||||
public function testSummaryCountsLinesAndBytes(): void
|
||||
{
|
||||
self::assertSame(
|
||||
'Eigenes CSS: 1 Zeilen hinzugefügt, 1 entfernt (3 → 3 Bytes).',
|
||||
CustomCssDiff::summary("a\nb", "a\nc")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\Design\CustomCssGuard;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/** Security audit 2026-11: what site-wide custom CSS may contain. */
|
||||
final class CustomCssGuardTest extends TestCase
|
||||
{
|
||||
private static function guard(): CustomCssGuard
|
||||
{
|
||||
return new CustomCssGuard('kunde.test');
|
||||
}
|
||||
|
||||
public function testPlainCssPassesUnchangedAndWithoutWarnings(): void
|
||||
{
|
||||
$css = ".a > b { color: #333; content: \">\"; }\n@media (min-width: 600px) { .x { margin: 0 } }\n"
|
||||
. ".y { background: url(data:image/png;base64,AAAA) } .z { background: url(/wp-content/a.png) }\n"
|
||||
. ".s { background: url(\"data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'/>\") }\n"
|
||||
. '.f { background: url(https://kunde.test/a.png) }';
|
||||
|
||||
self::assertSame(['errors' => [], 'warnings' => []], self::guard()->check($css));
|
||||
}
|
||||
|
||||
/** @return array<string, list<string>> */
|
||||
public static function refused(): array
|
||||
{
|
||||
return [
|
||||
'end of the style element' => ['</style><script>alert(1)</script>'],
|
||||
'end tag in capitals with a space' => ['a{} </STYLE >'],
|
||||
'script tag' => ['a{} <script src=x>'],
|
||||
'html comment open' => ['a{} <!-- x'],
|
||||
'html comment close' => ['a{} --> b{}'],
|
||||
'escaped less-than' => ['a{} \\3c /style>'],
|
||||
'escaped with zeros' => ['a{} \\00003c /style>'],
|
||||
'backslash before a slash' => ['a{} <\\/style>'],
|
||||
'expression' => ['a{ width: expression(alert(1)) }'],
|
||||
'expression split by a comment' => ['a{ width: exp/**/ression(alert(1)) }'],
|
||||
'javascript url' => ['a{ background: url(javascript:alert(1)) }'],
|
||||
'javascript with an escaped letter' => ['a{ background: url(j\\61vascript:alert(1)) }'],
|
||||
'vbscript' => ['a{ background: url(vbscript:x) }'],
|
||||
'behavior' => ['a{ behavior: url(x.htc) }'],
|
||||
'moz-binding' => ['a{ -moz-binding: url(x.xml#y) }'],
|
||||
'import from another host' => ['@import url("https://evil.test/x.css");'],
|
||||
'import without url()' => ['@import "https://evil.test/x.css";'],
|
||||
'import protocol relative' => ['@import url(//evil.test/x.css);'],
|
||||
'import of a data url' => ["@import 'data:text/css,a{color:red}';"],
|
||||
'import of an own file' => ['@import url("/wp-content/b.css");'],
|
||||
// The red-team run of the first guard: each of these loaded a foreign stylesheet and passed.
|
||||
'import without whitespace' => ['@import"https://evil.test/x.css";'],
|
||||
'import with a comment in between' => ['@import/**/"https://evil.test/x.css";'],
|
||||
'import with a comment inside a string before it' => ['@import "/*"; @import url(https://evil.test/x.css); @import "*/";'],
|
||||
'import with user info naming the own host' => ['@import "https://kunde.test:x@evil.test/x.css";'],
|
||||
'import with a tab hidden as escape' => ['@import "/\\9/evil.test/x.css";'],
|
||||
'import with a literal tab' => ["@import \"/\t/evil.test/x.css\";"],
|
||||
'import with a newline escape' => ['@import "/\\a/evil.test/x.css";'],
|
||||
'import scheme broken by a tab' => ['@import "h\\9ttps://evil.test/x.css";'],
|
||||
'import with a leading control character' => ['@import "\\1 https://evil.test/x.css";'],
|
||||
'import in capitals' => ['@IMPORT url(https://evil.test/x.css);'],
|
||||
'import spelled with an escape' => ['@\\69mport url(https://evil.test/x.css);'],
|
||||
// Old Internet Explorer read these as script.
|
||||
'full-width expression' => ['a{ width: expression(alert(1)) }'],
|
||||
'javascript broken by a tab escape' => ['a{ background: url(java\\9script:alert(1)) }'],
|
||||
'expression with a null escape' => ['a{ width: exp\\0ression(alert(1)) }'],
|
||||
'null byte' => ["a{}\0b{}"],
|
||||
'invalid utf-8' => ["a{ content: \"\xC3\x28\" }"],
|
||||
];
|
||||
}
|
||||
|
||||
/** @dataProvider refused */
|
||||
public function testRefusesWhatLeavesTheStyleElementOrCarriesScript(string $css): void
|
||||
{
|
||||
$verdict = self::guard()->check($css);
|
||||
|
||||
self::assertNotSame([], $verdict['errors'], $css);
|
||||
self::assertSame([], $verdict['warnings'], 'a refused text gets no further analysis');
|
||||
}
|
||||
|
||||
public function testRefusesATextOverTheSizeLimit(): void
|
||||
{
|
||||
$verdict = self::guard()->check(str_repeat('a', 2000001));
|
||||
|
||||
self::assertStringContainsString('größer als 2000000 Bytes', $verdict['errors'][0]);
|
||||
self::assertSame([], self::guard()->check(str_repeat('a', 2000000))['errors']);
|
||||
}
|
||||
|
||||
public function testNamesTheRefusedImport(): void
|
||||
{
|
||||
$verdict = self::guard()->check('@import url("https://evil.test/x.css");');
|
||||
|
||||
self::assertCount(1, $verdict['errors']);
|
||||
self::assertStringContainsString('@import ist nicht erlaubt', $verdict['errors'][0]);
|
||||
self::assertStringContainsString('https://evil.test/x.css', $verdict['errors'][0]);
|
||||
}
|
||||
|
||||
/** A site with a font import must still be editable: only what the change introduces is judged. */
|
||||
public function testAnImportThatIsAlreadyThereDoesNotBlockAnUnrelatedEdit(): void
|
||||
{
|
||||
$existing = '@import url("https://fonts.googleapis.com/css?family=Roboto");' . "\n.a { color: red; }";
|
||||
|
||||
self::assertSame([], self::guard()->check($existing . "\n.b { margin: 0; }", $existing)['errors']);
|
||||
self::assertSame([], self::guard()->check('.a { color: red; }', $existing)['errors'], 'removing it is fine');
|
||||
}
|
||||
|
||||
/** @return array<string, list<string>> */
|
||||
public static function changedImports(): array
|
||||
{
|
||||
return [
|
||||
'another host in the same statement' => ['@import url("https://evil.test/x.css");'],
|
||||
'a second import of the same file' => ['@import url("https://fonts.googleapis.com/css?family=Roboto");' . "\n"
|
||||
. '@import url("https://fonts.googleapis.com/css?family=Roboto");'],
|
||||
'one more import' => ['@import url("https://fonts.googleapis.com/css?family=Roboto"); @import "other.css";'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @dataProvider changedImports */
|
||||
public function testAChangedOrAdditionalImportIsRefusedEvenIfOneIsAlreadyThere(string $after): void
|
||||
{
|
||||
$existing = '@import url("https://fonts.googleapis.com/css?family=Roboto");';
|
||||
|
||||
self::assertNotSame([], self::guard()->check($after, $existing)['errors']);
|
||||
}
|
||||
|
||||
/** A dangerous text that is already in the CSS does not allow another one. */
|
||||
public function testAnExistingForbiddenPatternAllowsNoSecondOne(): void
|
||||
{
|
||||
self::assertSame([], self::guard()->check("a{} </style>\n.b{}", "a{} </style>")['errors']);
|
||||
self::assertNotSame([], self::guard()->check('a{} </style> </style>', 'a{} </style>')['errors']);
|
||||
}
|
||||
|
||||
/** The first guard let a huge text pass when PCRE gave up; and its comment regex was quadratic without JIT. */
|
||||
public function testALargeUnclosedCommentCannotHideAnImportAndIsCheckedFast(): void
|
||||
{
|
||||
$start = microtime(true);
|
||||
$hidden = self::guard()->check('@import url(https://evil.test/x.css);/*' . str_repeat('a', 1000000));
|
||||
$many = self::guard()->check(str_repeat('/*a', 600000));
|
||||
|
||||
self::assertNotSame([], $hidden['errors']);
|
||||
self::assertSame([], $many['errors']);
|
||||
self::assertLessThan(5.0, microtime(true) - $start);
|
||||
}
|
||||
|
||||
public function testAnotherHostInUrlOnlyWarns(): void
|
||||
{
|
||||
$verdict = self::guard()->check(
|
||||
'.a { background: url(https://cdn.test/a.png) } @font-face { src: url("//fonts.test/f.woff2") }'
|
||||
);
|
||||
|
||||
self::assertSame([], $verdict['errors']);
|
||||
self::assertCount(1, $verdict['warnings']);
|
||||
self::assertStringContainsString('„cdn.test“, „fonts.test“', $verdict['warnings'][0]);
|
||||
}
|
||||
|
||||
/** @return array<string, list<string>> */
|
||||
public static function otherHosts(): array
|
||||
{
|
||||
return [
|
||||
'image-set string' => ['a{ background: image-set("https://evil.test/a.png" 1x) }'],
|
||||
'inside a string that looks like a comment' => ['a{content:"/*"}a{background:url(https://evil.test/a.png)}a{content:"*/"}'],
|
||||
'user info naming the own host' => ['a{ background: url(https://kunde.test:x@evil.test/a.png) }'],
|
||||
'tab in the url' => ["a{ background: url(\"/\t/evil.test/a.png\") }"],
|
||||
'escaped tab' => ['a{ background: url("/\\9/evil.test/a.png") }'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @dataProvider otherHosts */
|
||||
public function testHostsAreFoundHoweverTheUrlIsWritten(string $css): void
|
||||
{
|
||||
$verdict = self::guard()->check($css);
|
||||
|
||||
self::assertSame([], $verdict['errors']);
|
||||
self::assertStringContainsString('evil.test', implode(' ', $verdict['warnings']), $css);
|
||||
}
|
||||
|
||||
public function testUnbalancedBracesWarn(): void
|
||||
{
|
||||
self::assertStringContainsString('Klammern', self::guard()->check('.a { color: red;')['warnings'][0]);
|
||||
// A brace inside a string is no brace.
|
||||
self::assertSame([], self::guard()->check('.a::after { content: "}" }')['warnings']);
|
||||
}
|
||||
|
||||
/** @return array<string, list<string>> */
|
||||
public static function hidingRules(): array
|
||||
{
|
||||
return [
|
||||
'body none' => ['body { display: none }'],
|
||||
'html and body hidden' => ["html, body {\n visibility: hidden;\n}"],
|
||||
'everything unclickable' => ['* { pointer-events: none }'],
|
||||
'page container transparent' => ['#page-container { opacity: 0 }'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @dataProvider hidingRules */
|
||||
public function testRulesThatHideThePageWarn(string $css): void
|
||||
{
|
||||
$verdict = self::guard()->check($css);
|
||||
|
||||
self::assertSame([], $verdict['errors']);
|
||||
self::assertStringContainsString('blendet die Seite aus', implode(' ', $verdict['warnings']));
|
||||
}
|
||||
|
||||
public function testOrdinaryHidingOfAnElementDoesNotWarn(): void
|
||||
{
|
||||
self::assertSame([], self::guard()->check('.popup { display: none } body .x { opacity: 0.5 }')['warnings']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Integrations\Divi\Design\CustomCssArea;
|
||||
use LIT\MCP\Integrations\Divi\Design\CustomCssRestorePlanner;
|
||||
use LIT\MCP\Integrations\Divi\Design\DesignPlan;
|
||||
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsAdapter;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||||
use LIT\MCP\Tests\Support\FakeCustomCssStore;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
|
||||
final class CustomCssRestorePlannerTest extends TestCase
|
||||
{
|
||||
private function planner(bool $customize = true): CustomCssRestorePlanner
|
||||
{
|
||||
$options = Mockery::mock(ThemeOptionsAdapter::class);
|
||||
$options->allows('canCustomize')->andReturn($customize);
|
||||
|
||||
return new CustomCssRestorePlanner(new FakeCustomCssStore(), $options);
|
||||
}
|
||||
|
||||
public function testDescribesItsAreaRightsAndFeatures(): void
|
||||
{
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
$planner = $this->planner();
|
||||
|
||||
self::assertSame('custom_css', $planner->area());
|
||||
self::assertInstanceOf(CustomCssArea::class, $planner->designArea());
|
||||
self::assertSame([DiviFeature::ThemeOptionRead, DiviFeature::ThemeOptionWrite], $planner->features());
|
||||
self::assertNull($planner->rights());
|
||||
|
||||
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_css');
|
||||
self::assertToolError($planner->rights(), 'forbidden');
|
||||
}
|
||||
|
||||
public function testThePlanReplacesTheWholeTextWithTheSnapshots(): void
|
||||
{
|
||||
$plan = $this->planner()->plan(
|
||||
['stylesheet' => 'Divi', 'css' => ".new{}\n.keep{}"],
|
||||
['stylesheet' => 'Divi', 'css' => ".old{}\n.keep{}"],
|
||||
'custom_css-20261105T100000Z-abc123'
|
||||
);
|
||||
|
||||
self::assertInstanceOf(DesignPlan::class, $plan);
|
||||
self::assertSame(['stylesheet' => 'Divi', 'css' => ".old{}\n.keep{}"], $plan->raw);
|
||||
self::assertTrue($plan->mayRemove);
|
||||
self::assertSame([
|
||||
['action' => 'removed', 'line' => '.new{}'],
|
||||
['action' => 'added', 'line' => '.old{}'],
|
||||
], $plan->diff);
|
||||
self::assertStringStartsWith('Snapshot custom_css-20261105T100000Z-abc123 wiederherstellen', $plan->summary);
|
||||
self::assertSame([CustomCssArea::SITE_WIDE], $plan->warnings);
|
||||
}
|
||||
|
||||
public function testAnUnchangedTextIsAnEmptyPlan(): void
|
||||
{
|
||||
$plan = $this->planner()->plan(
|
||||
['stylesheet' => 'Divi', 'css' => '.a{}'],
|
||||
['stylesheet' => 'Divi', 'css' => '.a{}'],
|
||||
'custom_css-x'
|
||||
);
|
||||
|
||||
self::assertInstanceOf(DesignPlan::class, $plan);
|
||||
self::assertTrue($plan->isEmpty());
|
||||
self::assertSame([], $plan->warnings);
|
||||
}
|
||||
|
||||
/** A snapshot of another theme would land in this theme's CSS. */
|
||||
public function testASnapshotOfAnotherThemeIsRefused(): void
|
||||
{
|
||||
$result = $this->planner()->plan(
|
||||
['stylesheet' => 'Divi', 'css' => '.a{}'],
|
||||
['stylesheet' => 'Child', 'css' => '.b{}'],
|
||||
'custom_css-x'
|
||||
);
|
||||
|
||||
$error = self::assertToolError($result, 'validation_failed');
|
||||
self::assertStringContainsString('Theme „Child“, aktiv ist „Divi“', $error['message']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?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\Integrations\Divi\Design\CustomCssArea;
|
||||
use LIT\MCP\Integrations\Divi\Design\DiviCustomCssGetAbility;
|
||||
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsAdapter;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||||
use LIT\MCP\Tests\Support\FakeCustomCssStore;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
|
||||
final class DiviCustomCssGetAbilityTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
}
|
||||
|
||||
private function ability(FakeCustomCssStore $store, bool $customize = true): DiviCustomCssGetAbility
|
||||
{
|
||||
$options = Mockery::mock(ThemeOptionsAdapter::class);
|
||||
$options->allows('canCustomize')->andReturn($customize);
|
||||
|
||||
return new DiviCustomCssGetAbility($store, $options);
|
||||
}
|
||||
|
||||
public function testMeta(): void
|
||||
{
|
||||
$ability = $this->ability(new FakeCustomCssStore());
|
||||
|
||||
self::assertSame('lit/divi-custom-css-get', $ability->name());
|
||||
self::assertSame(Annotations::read(), $ability->annotations());
|
||||
self::assertFalse($ability->isDirect());
|
||||
self::assertSame([DiviFeature::ThemeOptionRead], $ability->diviFeatures([]));
|
||||
self::assertStringContainsString('keine Anweisung', $ability->description());
|
||||
}
|
||||
|
||||
public function testReturnsTextLinesBytesAndTheRevTheUpdateExpects(): void
|
||||
{
|
||||
$store = new FakeCustomCssStore(".a { color: red; }\n.b { margin: 0; }", 'Divi');
|
||||
|
||||
$result = $this->ability($store)->execute([]);
|
||||
|
||||
self::assertSame(".a { color: red; }\n.b { margin: 0; }", $result['css']);
|
||||
self::assertSame('Divi', $result['stylesheet']);
|
||||
self::assertSame(2, $result['lines']);
|
||||
self::assertSame(36, $result['bytes']);
|
||||
self::assertSame((new CustomCssArea($store))->rev($store->read()), $result['design_rev']);
|
||||
}
|
||||
|
||||
public function testEmptyCssHasNoLines(): void
|
||||
{
|
||||
$result = $this->ability(new FakeCustomCssStore(''))->execute([]);
|
||||
|
||||
self::assertSame(['css' => '', 'lines' => 0, 'bytes' => 0], array_intersect_key($result, array_flip(['css', 'lines', 'bytes'])));
|
||||
}
|
||||
|
||||
public function testNeedsEditCssAndTheCustomizerRight(): void
|
||||
{
|
||||
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_css');
|
||||
self::assertToolError($this->ability(new FakeCustomCssStore())->execute([]), 'forbidden');
|
||||
self::assertToolError($this->ability(new FakeCustomCssStore())->permission([]), 'forbidden');
|
||||
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
self::assertToolError($this->ability(new FakeCustomCssStore(), customize: false)->execute([]), 'forbidden');
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,8 @@ final class DiviIntegrationTest extends TestCase
|
||||
'lit/divi-variables-set',
|
||||
'lit/divi-theme-options-get',
|
||||
'lit/divi-theme-options-update',
|
||||
'lit/divi-custom-css-get',
|
||||
'lit/divi-custom-css-update',
|
||||
'lit/divi-fonts-upload',
|
||||
];
|
||||
|
||||
|
||||
@@ -71,6 +71,8 @@ final class DiviToolFeaturesTest extends TestCase
|
||||
'lit/divi-variables-set' => ['design-read', 'variable-write'],
|
||||
'lit/divi-theme-options-get' => ['theme-option-read'],
|
||||
'lit/divi-theme-options-update' => ['theme-option-read', 'theme-option-write'],
|
||||
'lit/divi-custom-css-get' => ['theme-option-read'],
|
||||
'lit/divi-custom-css-update' => ['theme-option-read', 'theme-option-write'],
|
||||
'lit/divi-fonts-upload' => ['font-upload'],
|
||||
// Library (divi-library)
|
||||
'lit/library-list' => ['library-read', 'roles'],
|
||||
@@ -369,6 +371,7 @@ final class DiviToolFeaturesTest extends TestCase
|
||||
'lit/divi-presets-set' => 'preset-write', 'lit/divi-presets-delete' => 'preset-write',
|
||||
'lit/divi-colors-set' => 'color-write', 'lit/divi-variables-set' => 'variable-write',
|
||||
'lit/divi-theme-options-update' => 'theme-option-write', 'lit/divi-fonts-upload' => 'font-upload',
|
||||
'lit/divi-custom-css-update' => 'theme-option-write',
|
||||
'lit/divi-design-restore' => 'preset-write', 'lit/divi-design-detach' => 'preset-write',
|
||||
'lit/divi-presets-assign' => 'page-write', 'lit/tb-assign' => 'tb-write', 'lit/tb-activate' => 'tb-write',
|
||||
'lit/tb-create-template' => 'tb-write',
|
||||
|
||||
@@ -58,8 +58,8 @@ final class UninstallTest extends TestCase
|
||||
Access::OPTION, ExposurePolicy::OPTION, Limits::OPTION, ChangeLogInstaller::VERSION_OPTION,
|
||||
ReleaseStore::OPTION, CheckReportStore::OPTION, DiviCheckRoute::LOCK,
|
||||
'lit_mcp_snapshots_colors', 'lit_mcp_snapshots_variables', 'lit_mcp_snapshots_presets',
|
||||
'lit_mcp_snapshots_theme_options',
|
||||
DesignLock::option('colors'), DesignLock::option('variables'), DesignLock::option('presets'),
|
||||
'lit_mcp_snapshots_theme_options', 'lit_mcp_snapshots_custom_css',
|
||||
DesignLock::option('colors'), DesignLock::option('variables'), DesignLock::option('presets'), DesignLock::option('custom_css'),
|
||||
// Locks of versions before the shared one (audit L5) may still sit in the table.
|
||||
'lit_mcp_lock_colors', 'lit_mcp_lock_theme_options',
|
||||
RecheckOnInstall::OPTION,
|
||||
|
||||
@@ -64,8 +64,8 @@ final class UninstallerTest extends TestCase
|
||||
Access::OPTION, ExposurePolicy::OPTION, Limits::OPTION, ChangeLogInstaller::VERSION_OPTION,
|
||||
ReleaseStore::OPTION, CheckReportStore::OPTION, DiviCheckRoute::LOCK,
|
||||
'lit_mcp_snapshots_colors', 'lit_mcp_snapshots_variables', 'lit_mcp_snapshots_presets',
|
||||
'lit_mcp_snapshots_theme_options',
|
||||
DesignLock::option('colors'), DesignLock::option('variables'), DesignLock::option('presets'),
|
||||
'lit_mcp_snapshots_theme_options', 'lit_mcp_snapshots_custom_css',
|
||||
DesignLock::option('colors'), DesignLock::option('variables'), DesignLock::option('presets'), DesignLock::option('custom_css'),
|
||||
// Locks of versions before the shared one (audit L5) may still sit in the table.
|
||||
'lit_mcp_lock_colors', 'lit_mcp_lock_theme_options',
|
||||
RecheckOnInstall::OPTION,
|
||||
|
||||
Reference in New Issue
Block a user