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 $input * @return array */ 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 (['', "@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]); } }