Repository: symfony/ux (php)

Issue:
[LiveComponent] Allow specifying a live-component specific secret which defaults to kernel.secret
Allowing to specify a LiveComponent specific secret which, (if not specified, defaults to `kernel.secret`) would increase the security of the feature.

Namely, one use case we had is [we wanted a Node app to be able to render correct live component blocks](https://github.com/symfony/ux/pull/2451#issuecomment-2551963793). To do that, it needs to share the key with Symfony's LiveComponent implementation to generate the signatures which get verified by Symfony for it to work. We'd prefer to not have to share the `kernel.secret` with the node app (even though it's internal and built by us, trusted) because that provides ways to attack the Symfony app in other ways (login links, RCE via fragments) assuming it leaks.

Simply making this secret configurable would improve the security overall because the secret would then have a tiny scope (only that one specific thing) and be intended to be shared with the Node collaborator.

A similar thing was done somewhere on Symfony main repo by @nicolas-grekas recently (login links?)

Pull request description:
[LiveComponent] Allow configuring secret for fingerprints and checksums
| Q             | A
| ------------- | ---
| Bug fix?      | no
| New feature?  | yes
| Issues        | Fix #2453
| License       | MIT

Allow to configure a dedicated secret (used in FingerprintCalculator and LiveComonentHydrator)

Suggested by @dkarlovi in #2453 
Implementation inspired by [symfony #56840](https://github.com/symfony/symfony/pull/56840)

Should be merged _after_  #2461

Patch:
diff --git a/src/Autocomplete/CHANGELOG.md b/src/Autocomplete/CHANGELOG.md
index 1f8cea05..d7384fee 100644
--- a/src/Autocomplete/CHANGELOG.md
+++ b/src/Autocomplete/CHANGELOG.md
@@ -1,10 +1,5 @@
 # CHANGELOG
 
-## 2.23.0
-
--   Deprecate `ExtraLazyChoiceLoader` in favor of `Symfony\Component\Form\ChoiceList\Loader\LazyChoiceLoader`
--   Reset TomSelect when updating url attribute #1505
-
 ## 2.22.0
 
 -   Take `labelField` TomSelect option into account #2382
diff --git a/src/Autocomplete/assets/dist/controller.d.ts b/src/Autocomplete/assets/dist/controller.d.ts
index d5cc6b3e..59938a72 100644
--- a/src/Autocomplete/assets/dist/controller.d.ts
+++ b/src/Autocomplete/assets/dist/controller.d.ts
@@ -40,7 +40,6 @@ export default class extends Controller {
     connect(): void;
     initializeTomSelect(): void;
     disconnect(): void;
-    urlValueChanged(): void;
     private getMaxOptions;
     get selectElement(): HTMLSelectElement | null;
     get formElement(): HTMLInputElement | HTMLSelectElement;
diff --git a/src/Autocomplete/assets/dist/controller.js b/src/Autocomplete/assets/dist/controller.js
index e021770d..fb04294a 100644
--- a/src/Autocomplete/assets/dist/controller.js
+++ b/src/Autocomplete/assets/dist/controller.js
@@ -91,9 +91,6 @@ class default_1 extends Controller {
             }
         }
     }
-    urlValueChanged() {
-        this.resetTomSelect();
-    }
     getMaxOptions() {
         return this.selectElement ? this.selectElement.options.length : 50;
     }
@@ -134,6 +131,7 @@ class default_1 extends Controller {
             this.element.innerHTML = currentHtml;
             this.initializeTomSelect();
             this.tomSelect.setValue(currentValue);
+            this.startMutationObserver();
         }
     }
     changeTomSelectDisabledState(isDisabled) {
diff --git a/src/Autocomplete/assets/src/controller.ts b/src/Autocomplete/assets/src/controller.ts
index 6b68283b..38b324fc 100644
--- a/src/Autocomplete/assets/src/controller.ts
+++ b/src/Autocomplete/assets/src/controller.ts
@@ -128,10 +128,6 @@ export default class extends Controller {
         }
     }
 
-    urlValueChanged() {
-        this.resetTomSelect();
-    }
-
     #getCommonConfig(): Partial<TomSettings> {
         const plugins: TPluginHash = {};
 
@@ -395,6 +391,8 @@ export default class extends Controller {
             this.element.innerHTML = currentHtml;
             this.initializeTomSelect();
             this.tomSelect.setValue(currentValue);
+
+            this.startMutationObserver();
         }
     }
 
diff --git a/src/Autocomplete/src/Form/BaseEntityAutocompleteType.php b/src/Autocomplete/src/Form/BaseEntityAutocompleteType.php
index 18804b77..f4e002d0 100644
--- a/src/Autocomplete/src/Form/BaseEntityAutocompleteType.php
+++ b/src/Autocomplete/src/Form/BaseEntityAutocompleteType.php
@@ -13,7 +13,6 @@ namespace Symfony\UX\Autocomplete\Form;
 
 use Symfony\Bridge\Doctrine\Form\Type\EntityType;
 use Symfony\Component\Form\AbstractType;
-use Symfony\Component\Form\ChoiceList\Loader\LazyChoiceLoader;
 use Symfony\Component\Form\Exception\RuntimeException;
 use Symfony\Component\Form\FormBuilderInterface;
 use Symfony\Component\OptionsResolver\Options;
@@ -43,10 +42,6 @@ final class BaseEntityAutocompleteType extends AbstractType
                 return null;
             }
 
-            if (class_exists(LazyChoiceLoader::class)) {
-                return new LazyChoiceLoader($loader);
-            }
-
             return new ExtraLazyChoiceLoader($loader);
         };
 
diff --git a/src/Autocomplete/src/Form/ChoiceList/Loader/ExtraLazyChoiceLoader.php b/src/Autocomplete/src/Form/ChoiceList/Loader/ExtraLazyChoiceLoader.php
index 8f49ab4b..dfa3b0b4 100644
--- a/src/Autocomplete/src/Form/ChoiceList/Loader/ExtraLazyChoiceLoader.php
+++ b/src/Autocomplete/src/Form/ChoiceList/Loader/ExtraLazyChoiceLoader.php
@@ -17,8 +17,6 @@ use Symfony\Component\Form\ChoiceList\Loader\ChoiceLoaderInterface;
 
 /**
  * Loads choices on demand only.
- *
- * @deprecated since Autocomplete 2.23 and will be removed in 3.0, use `Symfony\Component\Form\ChoiceList\Loader\LazyChoiceLoader` instead.
  */
 class ExtraLazyChoiceLoader implements ChoiceLoaderInterface
 {
diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md
index 5674d75e..a43e9518 100644
--- a/src/LiveComponent/CHANGELOG.md
+++ b/src/LiveComponent/CHANGELOG.md
@@ -1,5 +1,9 @@
 # CHANGELOG
 
+## 2.23.0
+
+-   Allow configuring the secret used to compute fingerprints and checksums.
+
 ## 2.22.0
 
 -   Remove CSRF tokens - rely on same-origin/CORS instead
diff --git a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php
index 28979e13..74283078 100644
--- a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php
+++ b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php
@@ -12,6 +12,10 @@
 namespace Symfony\UX\LiveComponent\DependencyInjection;
 
 use Symfony\Component\AssetMapper\AssetMapperInterface;
+use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
+use Symfony\Component\Config\Definition\Builder\TreeBuilder;
+use Symfony\Component\Config\Definition\ConfigurationInterface;
+use Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument;
 use Symfony\Component\DependencyInjection\ChildDefinition;
 use Symfony\Component\DependencyInjection\ContainerBuilder;
 use Symfony\Component\DependencyInjection\ContainerInterface;
@@ -50,14 +54,12 @@ use Symfony\UX\LiveComponent\Util\TwigAttributeHelperFactory;
 use Symfony\UX\TwigComponent\ComponentFactory;
 use Symfony\UX\TwigComponent\ComponentRenderer;
 
-use function Symfony\Component\DependencyInjection\Loader\Configurator\tagged_iterator;
-
 /**
  * @author Kevin Bond <kevinbond@gmail.com>
  *
  * @internal
  */
-final class LiveComponentExtension extends Extension implements PrependExtensionInterface
+final class LiveComponentExtension extends Extension implements PrependExtensionInterface, ConfigurationInterface
 {
     public const TEMPLATES_MAP_FILENAME = 'live_components_twig_templates.map';
 
@@ -93,16 +95,19 @@ final class LiveComponentExtension extends Extension implements PrependExtension
             }
         );
 
+        $configuration = $this->getConfiguration($configs, $container);
+        $config = $this->processConfiguration($configuration, $configs);
+
         $container->registerForAutoconfiguration(HydrationExtensionInterface::class)
             ->addTag(LiveComponentBundle::HYDRATION_EXTENSION_TAG);
 
         $container->register('ux.live_component.component_hydrator', LiveComponentHydrator::class)
             ->setArguments([
-                tagged_iterator(LiveComponentBundle::HYDRATION_EXTENSION_TAG),
+                new TaggedIteratorArgument(LiveComponentBundle::HYDRATION_EXTENSION_TAG),
                 new Reference('property_accessor'),
                 new Reference('ux.live_component.metadata_factory'),
                 new Reference('serializer', ContainerInterface::NULL_ON_INVALID_REFERENCE),
-                '%kernel.secret%',
+                $config['secret'], // defaults to '%kernel.secret%'
             ])
         ;
 
@@ -236,7 +241,7 @@ final class LiveComponentExtension extends Extension implements PrependExtension
 
         $container->register('ux.live_component.deterministic_id_calculator', DeterministicTwigIdCalculator::class);
         $container->register('ux.live_component.fingerprint_calculator', FingerprintCalculator::class)
-            ->setArguments(['%kernel.secret%']);
+            ->setArguments([$config['secret']]); // default to %kernel.secret%
 
         $container->setAlias(ComponentValidatorInterface::class, ComponentValidator::class);
 
@@ -258,6 +263,35 @@ final class LiveComponentExtension extends Extension implements PrependExtension
             ->addTag('kernel.cache_warmer');
     }
 
+    public function getConfigTreeBuilder(): TreeBuilder
+    {
+        $treeBuilder = new TreeBuilder('live_component');
+        $rootNode = $treeBuilder->getRootNode();
+        \assert($rootNode instanceof ArrayNodeDefinition);
+
+        $rootNode
+            ->addDefaultsIfNotSet()
+            ->children()
+                ->scalarNode('secret')
+                    ->info('The secret used to compute fingerprints and checksums')
+                    ->beforeNormalization()
+                        ->ifString()
+                        ->then(trim(...))
+                    ->end()
+                    ->cannotBeEmpty()
+                    ->defaultValue('%kernel.secret%')
+                ->end()
+            ->end()
+        ;
+
+        return $treeBuilder;
+    }
+
+    public function getConfiguration(array $config, ContainerBuilder $container): ConfigurationInterface
+    {
+        return $this;
+    }
+
     private function isAssetMapperAvailable(ContainerBuilder $container): bool
     {
         if (!interface_exists(AssetMapperInterface::class)) {
diff --git a/src/LiveComponent/src/LiveComponentHydrator.php b/src/LiveComponent/src/LiveComponentHydrator.php
index 95f4bf91..a5b23ce0 100644
--- a/src/LiveComponent/src/LiveComponentHydrator.php
+++ b/src/LiveComponent/src/LiveComponentHydrator.php
@@ -50,11 +50,8 @@ final class LiveComponentHydrator
         private PropertyAccessorInterface $propertyAccessor,
         private LiveComponentMetadataFactory $liveComponentMetadataFactory,
         private NormalizerInterface|DenormalizerInterface|null $serializer,
-        #[\SensitiveParameter] private string $secret,
+        private string $secret,
     ) {
-        if (!$secret) {
-            throw new \InvalidArgumentException('A non-empty secret is required.');
-        }
     }
 
     public function dehydrate(object $component, ComponentAttributes $attributes, LiveComponentMetadata $componentMetadata): DehydratedProps
diff --git a/src/LiveComponent/src/Util/FingerprintCalculator.php b/src/LiveComponent/src/Util/FingerprintCalculator.php
index 9134e1ca..0f3da0e9 100644
--- a/src/LiveComponent/src/Util/FingerprintCalculator.php
+++ b/src/LiveComponent/src/Util/FingerprintCalculator.php
@@ -24,14 +24,11 @@ use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadata;
  *
  * @internal
  */
-final class FingerprintCalculator
+class FingerprintCalculator
 {
     public function __construct(
-        #[\SensitiveParameter] private string $secret,
+        private string $secret,
     ) {
-        if (!$secret) {
-            throw new \InvalidArgumentException('A non-empty secret is required.');
-        }
     }
 
     public function calculateFingerprint(array $inputProps, LiveComponentMetadata $liveMetadata): string
diff --git a/src/TwigComponent/doc/index.rst b/src/TwigComponent/doc/index.rst
index 0b8d8b00..27d04c30 100644
--- a/src/TwigComponent/doc/index.rst
+++ b/src/TwigComponent/doc/index.rst
@@ -382,7 +382,7 @@ use the full path of the template where the macro is defined:
     <twig:Alert>
         {# ❌ this won't work #}
         {% from _self import message_formatter %}
-
+        
         {# ✅ this works as expected #}
         {% from 'path/of/this/template.html.twig' import message_formatter %}
 
@@ -1553,12 +1553,6 @@ are called additional times, the cached value is used.
     Computed methods only work for component methods with no required
     arguments.
 
-.. tip::
-
-    Ensure to not use the ``ExposeInTemplate`` attribute on a computed method,
-    otherwise the method will be called twice instead of only once, leading to
-    unnecessary overhead and potential performance issues.
-
 Events
 ------
 
diff --git a/ux.symfony.com/assets/styles/components/_IconModal.scss b/ux.symfony.com/assets/styles/components/_IconModal.scss
index 5f51fa84..381d446f 100644
--- a/ux.symfony.com/assets/styles/components/_IconModal.scss
+++ b/ux.symfony.com/assets/styles/components/_IconModal.scss
@@ -15,7 +15,7 @@
   min-height: 1rem;
   max-width: 100vw;
   max-height: 100vh;
-  overflow: auto;
+  overflow: clip;
   padding: 1rem 1.5rem;
   background: var(--bs-body-bg);
   color: var(--bs-body-color);
@@ -59,6 +59,7 @@
 
 .IconModal__body {
   display: grid;
+  grid-template-columns: 300px 1fr;
   gap: 1.5rem;
 }
 
@@ -175,7 +176,7 @@
   padding: 0.35rem 0.5rem;
   border-radius: .5rem;
   border: 1px solid var(--bs-dark-border-subtle);
-  background: var(--bs-secondary-bg);
+  background: var(--bs-secondary-bg-subtle);
   overflow: hidden;
   position: relative;
   opacity: .8;
@@ -211,7 +212,7 @@
 }
 
 .IconModal__code span {
-  color: var(--bs-code-color);
+  color: lightskyblue;
 }
 
 .IconModal__copy {
@@ -238,20 +239,3 @@
   padding-top: 0;
 }
 
-.IconModal__main .TabBody {
-    padding-block-end: 0;
-}
-
-@media screen and (max-width: 768px) {
-  .IconModal {
-    padding: .5rem;
-    margin: .5rem;
-    width: auto;
-    height: auto;
-  }
-}
-@media screen and (min-width: 768px) {
-  .IconModal__body {
-    grid-template-columns: 300px 1fr;
-  }
-}
diff --git a/ux.symfony.com/assets/styles/components/_Tabs.scss b/ux.symfony.com/assets/styles/components/_Tabs.scss
index 1ca29f84..b769ac56 100644
--- a/ux.symfony.com/assets/styles/components/_Tabs.scss
+++ b/ux.symfony.com/assets/styles/components/_Tabs.scss
@@ -30,7 +30,7 @@
   transition: background-color 150ms ease-in-out;
 }
 .TabControl.active {
-  background: var(--bs-tertiary-bg);
+  background: var(--bs-secondary-bg);
 }
 
 .TabPanel {


Test patch:
diff --git a/src/Autocomplete/assets/test/controller.test.ts b/src/Autocomplete/assets/test/controller.test.ts
index 5b72e7fb..e17dc6ae 100644
--- a/src/Autocomplete/assets/test/controller.test.ts
+++ b/src/Autocomplete/assets/test/controller.test.ts
@@ -138,80 +138,6 @@ describe('AutocompleteController', () => {
         expect(fetchMock.requests()[1].url).toEqual('/path/to/autocomplete?query=foo');
     });
 
-    it('resets when ajax URL attribute on a select element changes', async () => {
-        const { container, tomSelect } = await startAutocompleteTest(`
-            <label for="the-select">Items</label>
-            <select
-                id="the-select"
-                data-testid="main-element"
-                data-controller="autocomplete"
-                data-autocomplete-url-value="/path/to/autocomplete"
-            ></select>
-        `);
-
-        const selectElement = getByTestId(container, 'main-element') as HTMLSelectElement;
-
-        // initial Ajax request on focus
-        fetchMock.mockResponseOnce(
-            JSON.stringify({
-                results: [
-                    {
-                        value: 3,
-                        text: 'salad',
-                    },
-                ],
-            })
-        );
-
-        fetchMock.mockResponseOnce(
-            JSON.stringify({
-                results: [
-                    {
-                        value: 1,
-                        text: 'pizza',
-                    },
-                    {
-                        value: 2,
-                        text: 'popcorn',
-                    },
-                ],
-            })
-        );
-
-        const controlInput = tomSelect.control_input;
-
-        // wait for the initial Ajax request to finish
-        userEvent.click(controlInput);
-        await waitFor(() => {
-            expect(container.querySelectorAll('.option[data-selectable]')).toHaveLength(1);
-        });
-
-        controlInput.value = 'foo';
-        controlInput.dispatchEvent(new Event('input'));
-
-        await waitFor(() => {
-            expect(container.querySelectorAll('.option[data-selectable]')).toHaveLength(2);
-        });
-
-        expect(selectElement.value).toBe('');
-        tomSelect.addItem('2');
-        expect(selectElement.value).toBe('2');
-
-        selectElement.outerHTML = `
-            <select
-                id="the-select"
-                data-testid="main-element"
-                data-controller="autocomplete"
-                data-autocomplete-url-value="/path/to/autocomplete2"
-            ></select>
-        `;
-
-        // wait for the MutationObserver to flush these changes
-        await shortDelay(10);
-
-        expect(selectElement.value).toBe('');
-    });
-
     it('connect with ajax URL on an input element', async () => {
         const { container, tomSelect } = await startAutocompleteTest(`
             <label for="the-input">Items</label>
diff --git a/src/LiveComponent/tests/Unit/DependencyInjection/LiveComponentConfigurationTest.php b/src/LiveComponent/tests/Unit/DependencyInjection/LiveComponentConfigurationTest.php
new file mode 100644
index 00000000..72481a0e
--- /dev/null
+++ b/src/LiveComponent/tests/Unit/DependencyInjection/LiveComponentConfigurationTest.php
@@ -0,0 +1,53 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\LiveComponent\Tests\Unit\DependencyInjection;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
+use Symfony\Component\Config\Definition\Processor;
+use Symfony\UX\LiveComponent\DependencyInjection\LiveComponentExtension;
+
+class LiveComponentConfigurationTest extends TestCase
+{
+    public function testDefaultSecret()
+    {
+        $processor = new Processor();
+        $config = $processor->processConfiguration(new LiveComponentExtension(), []);
+
+        $this->assertEquals('%kernel.secret%', $config['secret']);
+    }
+
+    public function testEmptySecretThrows()
+    {
+        $this->expectException(InvalidConfigurationException::class);
+        $this->expectExceptionMessage('The path "live_component.secret" cannot contain an empty value, but got null.');
+
+        $processor = new Processor();
+        $config = $processor->processConfiguration(new LiveComponentExtension(), [
+            'live_component' => [
+                'secret' => null,
+            ],
+        ]);
+    }
+
+    public function testCustomSecret()
+    {
+        $processor = new Processor();
+        $config = $processor->processConfiguration(new LiveComponentExtension(), [
+            'live_component' => [
+                'secret' => 'my_secret',
+            ],
+        ]);
+
+        $this->assertEquals('my_secret', $config['secret']);
+    }
+}
diff --git a/src/LiveComponent/tests/Unit/DependencyInjection/LiveComponentExtensionTest.php b/src/LiveComponent/tests/Unit/DependencyInjection/LiveComponentExtensionTest.php
new file mode 100644
index 00000000..b70be7ed
--- /dev/null
+++ b/src/LiveComponent/tests/Unit/DependencyInjection/LiveComponentExtensionTest.php
@@ -0,0 +1,66 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\LiveComponent\Tests\Unit\DependencyInjection;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\DependencyInjection\ContainerBuilder;
+use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag;
+use Symfony\UX\LiveComponent\DependencyInjection\LiveComponentExtension;
+
+class LiveComponentExtensionTest extends TestCase
+{
+    public function testKernelSecretIsUsedByDefault(): void
+    {
+        $container = $this->createContainer();
+        $container->registerExtension(new LiveComponentExtension());
+        $container->loadFromExtension('live_component', []);
+        $this->compileContainer($container);
+
+        $this->assertSame('%kernel.secret%', $container->getDefinition('ux.live_component.component_hydrator')->getArgument(4));
+        $this->assertSame('%kernel.secret%', $container->getDefinition('ux.live_component.fingerprint_calculator')->getArgument(0));
+    }
+
+    public function testCustomSecretIsUsedInDefinition(): void
+    {
+        $container = $this->createContainer();
+        $container->registerExtension(new LiveComponentExtension());
+        $container->loadFromExtension('live_component', [
+            'secret' => 'custom_secret',
+        ]);
+        $this->compileContainer($container);
+
+        $this->assertSame('custom_secret', $container->getDefinition('ux.live_component.component_hydrator')->getArgument(4));
+        $this->assertSame('custom_secret', $container->getDefinition('ux.live_component.fingerprint_calculator')->getArgument(0));
+    }
+
+    private function createContainer(): ContainerBuilder
+    {
+        $container = new ContainerBuilder(new ParameterBag([
+            'kernel.cache_dir' => __DIR__,
+            'kernel.project_dir' => __DIR__,
+            'kernel.charset' => 'UTF-8',
+            'kernel.debug' => false,
+            'kernel.bundles' => [],
+            'kernel.bundles_metadata' => [],
+        ]));
+
+        return $container;
+    }
+
+    private function compileContainer(ContainerBuilder $container): void
+    {
+        $container->getCompilerPassConfig()->setOptimizationPasses([]);
+        $container->getCompilerPassConfig()->setRemovingPasses([]);
+        $container->getCompilerPassConfig()->setAfterRemovingPasses([]);
+        $container->compile();
+    }
+}
diff --git a/src/LiveComponent/tests/Unit/LiveComponentHydratorTest.php b/src/LiveComponent/tests/Unit/LiveComponentHydratorTest.php
deleted file mode 100644
index f14ff4a7..00000000
--- a/src/LiveComponent/tests/Unit/LiveComponentHydratorTest.php
+++ /dev/null
@@ -1,35 +0,0 @@
-<?php
-
-/*
- * This file is part of the Symfony package.
- *
- * (c) Fabien Potencier <fabien@symfony.com>
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-namespace Symfony\UX\LiveComponent\Tests\Unit;
-
-use PHPUnit\Framework\TestCase;
-use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
-use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
-use Symfony\UX\LiveComponent\LiveComponentHydrator;
-use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory;
-
-final class LiveComponentHydratorTest extends TestCase
-{
-    public function testConstructWithEmptySecret(): void
-    {
-        $this->expectException(\InvalidArgumentException::class);
-        $this->expectExceptionMessage('A non-empty secret is required.');
-
-        new LiveComponentHydrator(
-            [],
-            $this->createMock(PropertyAccessorInterface::class),
-            $this->createMock(LiveComponentMetadataFactory::class),
-            $this->createMock(NormalizerInterface::class),
-            '',
-        );
-    }
-}
diff --git a/src/LiveComponent/tests/Unit/Util/FingerprintCalculatorTest.php b/src/LiveComponent/tests/Unit/Util/FingerprintCalculatorTest.php
deleted file mode 100644
index 71f115d9..00000000
--- a/src/LiveComponent/tests/Unit/Util/FingerprintCalculatorTest.php
+++ /dev/null
@@ -1,26 +0,0 @@
-<?php
-
-/*
- * This file is part of the Symfony package.
- *
- * (c) Fabien Potencier <fabien@symfony.com>
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-namespace Symfony\UX\LiveComponent\Tests\Unit\Util;
-
-use PHPUnit\Framework\TestCase;
-use Symfony\UX\LiveComponent\Util\FingerprintCalculator;
-
-final class FingerprintCalculatorTest extends TestCase
-{
-    public function testConstructWithEmptySecret(): void
-    {
-        $this->expectException(\InvalidArgumentException::class);
-        $this->expectExceptionMessage('A non-empty secret is required.');
-
-        new FingerprintCalculator('');
-    }
-}