diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..0a0a926 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,59 @@ +name: Test + +on: + push: + branches: + - "**" + paths-ignore: + - "README.md" + pull_request: + types: [ready_for_review, synchronize, opened] + paths-ignore: + - "README.md" + +jobs: + build: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: [8.1, 8.2, 8.3] + laravel: [10.*, 11.*] + exclude: + - php: 8.1 + laravel: 11.* + + name: PHP:${{ matrix.php }} / Laravel:${{ matrix.laravel }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP, with composer and extensions + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, curl, libxml, mbstring, iconv, intl, zip, pdo_sqlite + tools: composer:v2 + coverage: none + + - name: Get composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache composer dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: dependencies-laravel-${{ matrix.laravel }}-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} + restore-keys: dependencies-laravel-${{ matrix.laravel }}-php-${{ matrix.php }}-composer- + + - name: Install Composer dependencies + run: | + composer require "laravel/framework:${{ matrix.laravel }}" --no-interaction --no-update --dev + composer update --prefer-stable --no-interaction + + - name: Run Unit tests + run: vendor/bin/phpunit --testsuite Unit + env: + RUNNING_IN_CI: true \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c52c27a --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +/vendor/ +/node_modules/ +_runtime_components.json +SCRATCH.md +COMMIT_MSG.aim.txt +composer.lock +.phpunit.cache/ +.phpunit.result.cache +.idea +phpunit.xml + +# OS generated files +.DS_Store +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8c37cc1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Philo Hermans + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bf7752f --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# Livewire Strict + +

+Total Downloads +Latest Stable Version +License +

+ +## Enforce additional Livewire security measures +Livewire Strict helps to enforce security measures and prevents you from leaving sensitive public properties unprotected. + +## Documentation +You can find the [full documentation on our site](https://wire-elements.dev/blog/livewire-strict-enforce-additional-security-measures-to-livewire). diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..b54a884 --- /dev/null +++ b/composer.json @@ -0,0 +1,39 @@ +{ + "name": "wire-elements/livewire-strict", + "description": "Add strict mode to Livewire.", + "require": { + "php": "^8.1" + }, + "license": "MIT", + "autoload": { + "psr-4": { + "WireElements\\LivewireStrict\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + }, + "authors": [ + { + "name": "Philo Hermans", + "email": "me@philo.dev" + } + ], + "extra": { + "laravel": { + "providers": [ + "WireElements\\LivewireStrict\\LivewireStrictServiceProvider" + ] + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "require-dev": { + "phpunit/phpunit": "^10.4", + "laravel/framework": "^10.15.0|^11.0", + "orchestra/testbench": "^8.21.0|^9.1", + "livewire/livewire": "^3.5" + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..81775b0 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,20 @@ + + + + + ./src + + + + + + + \ No newline at end of file diff --git a/src/Attributes/Unlocked.php b/src/Attributes/Unlocked.php new file mode 100644 index 0000000..e27f91d --- /dev/null +++ b/src/Attributes/Unlocked.php @@ -0,0 +1,10 @@ +component + ->getAttributes() + ->whereInstanceOf(Unlocked::class) + ->filter(fn (Unlocked $attribute) => $attribute->getLevel() === AttributeLevel::ROOT) + ->isNotEmpty(); + + if ($componentIsUnlocked) { + return; + } + + $propertyIsUnlocked = $this->component + ->getAttributes() + ->whereInstanceOf(Unlocked::class) + ->filter(fn (Unlocked $attribute) => $attribute->getSubName() === $propertyName && $attribute->getLevel() === AttributeLevel::PROPERTY) + ->isNotEmpty(); + + throw_unless($propertyIsUnlocked, CannotUpdateLockedPropertyException::class, $propertyName); + } +} diff --git a/src/Features/SupportLockedProperties/UnitTest.php b/src/Features/SupportLockedProperties/UnitTest.php new file mode 100644 index 0000000..60e6284 --- /dev/null +++ b/src/Features/SupportLockedProperties/UnitTest.php @@ -0,0 +1,72 @@ +expectExceptionMessage( + 'Cannot update locked property: [count]' + ); + + LivewireStrict::lockProperties(); + + Livewire::test(new class extends TestComponent + { + public $count = 1; + + public function increment() + { + $this->count++; + } + }) + ->assertSetStrict('count', 1) + ->set('count', 2); + } + + public function test_can_update_unlocked_property() + { + LivewireStrict::lockProperties(); + + Livewire::test(new class extends TestComponent + { + #[Unlocked] + public $count = 1; + }) + ->assertSetStrict('count', 1) + ->set('count', 2); + } + + public function test_can_update_unlocked_component() + { + LivewireStrict::lockProperties(); + + Livewire::test(new #[Unlocked] class extends TestComponent + { + public $count = 1; + }) + ->assertSetStrict('count', 1) + ->set('count', 2); + } +} + +class TestComponent extends Component +{ + public function render() + { + return '
'; + } +} diff --git a/src/LivewireStrict.php b/src/LivewireStrict.php new file mode 100644 index 0000000..52c9c31 --- /dev/null +++ b/src/LivewireStrict.php @@ -0,0 +1,22 @@ +componentHook(Features\SupportLockedProperties\SupportLockedProperties::class); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..e50e707 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,43 @@ +afterApplicationCreated(function () { + $this->makeACleanSlate(); + }); + + $this->beforeApplicationDestroyed(function () { + $this->makeACleanSlate(); + }); + + parent::setUp(); + } + + public function makeACleanSlate() + { + Artisan::call('view:clear'); + + File::delete(app()->bootstrapPath('cache/livewire-components.php')); + } + + protected function getPackageProviders($app) + { + return [ + \Livewire\LivewireServiceProvider::class, + LivewireStrictServiceProvider::class, + ]; + } + + protected function defineEnvironment($app) + { + $app['config']->set('app.key', 'base64:Hupx3yAySikrM2/edkZQNQHslgDWYfiBfCuSThJ5SK8='); + } +}