diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..dd9a2b5 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c09f81e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,20 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore all test and documentation with "export-ignore". +/.github export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpunit.xml.dist export-ignore +/art export-ignore +/docs export-ignore +/tests export-ignore +/workbench export-ignore +/.editorconfig export-ignore +/.php_cs.dist.php export-ignore +/psalm.xml export-ignore +/psalm.xml.dist export-ignore +/testbench.yaml export-ignore +/UPGRADING.md export-ignore +/phpstan.neon.dist export-ignore +/phpstan-baseline.neon export-ignore diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..e196b2b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,66 @@ +name: Bug Report +description: Report an Issue or Bug with the Package +title: "[Bug]: " +labels: [ "bug" ] +body: + - type: markdown + attributes: + value: | + We're sorry to hear you have a problem. Can you help us solve it by providing the following details. + - type: textarea + id: what-happened + attributes: + label: What happened? + description: What did you expect to happen? + placeholder: I cannot currently do X thing because when I do, it breaks X thing. + validations: + required: true + - type: textarea + id: how-to-reproduce + attributes: + label: How to reproduce the bug + description: How did this occur, please add any config values used and provide a set of reliable steps if possible. + placeholder: When I do X I see Y. + validations: + required: true + - type: input + id: package-version + attributes: + label: Package Version + description: What version of our Package are you running? Please be as specific as possible + placeholder: 2.0.0 + validations: + required: true + - type: input + id: php-version + attributes: + label: PHP Version + description: What version of PHP are you running? Please be as specific as possible + placeholder: 8.2.0 + validations: + required: true + - type: input + id: laravel-version + attributes: + label: Laravel Version + description: What version of Laravel are you running? Please be as specific as possible + placeholder: 11.0.0 + validations: + required: true + - type: dropdown + id: operating-systems + attributes: + label: Which operating systems does with happen with? + description: You may select more than one. + multiple: true + options: + - macOS + - Windows + - Linux + - type: textarea + id: notes + attributes: + label: Notes + description: Use this field to provide any other notes that you feel might be relevant to the issue. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..1a2aa18 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Ask a question + url: https://github.com/ephort/laravel-data-authorization/discussions/new?category=q-a + about: Ask the community for help + - name: Request a feature + url: https://github.com/ephort/laravel-data-authorization/discussions/new?category=ideas + about: Share ideas for new features diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..39b1580 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,19 @@ +# Please see the documentation for all configuration options: +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + labels: + - "dependencies" + + - package-ecosystem: "composer" + directory: "/" + schedule: + interval: "weekly" + labels: + - "dependencies" diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 0000000..056063f --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,33 @@ +name: dependabot-auto-merge +on: pull_request_target + +permissions: + pull-requests: write + contents: write + +jobs: + dependabot: + runs-on: ubuntu-latest + timeout-minutes: 5 + if: ${{ github.actor == 'dependabot[bot]' }} + steps: + + - name: Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@v2.1.0 + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" + + - name: Auto-merge Dependabot PRs for semver-minor updates + if: ${{steps.metadata.outputs.update-type == 'version-update:semver-minor'}} + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + + - name: Auto-merge Dependabot PRs for semver-patch updates + if: ${{steps.metadata.outputs.update-type == 'version-update:semver-patch'}} + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/.github/workflows/fix-php-code-style-issues.yml b/.github/workflows/fix-php-code-style-issues.yml new file mode 100644 index 0000000..1c4c15c --- /dev/null +++ b/.github/workflows/fix-php-code-style-issues.yml @@ -0,0 +1,28 @@ +name: Fix PHP code style issues + +on: + push: + paths: + - '**.php' + +permissions: + contents: write + +jobs: + php-code-styling: + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + + - name: Fix PHP code style issues + uses: aglipanci/laravel-pint-action@2.4 + + - name: Commit changes + uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: "style: run Pint" diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml new file mode 100644 index 0000000..734da39 --- /dev/null +++ b/.github/workflows/phpstan.yml @@ -0,0 +1,28 @@ +name: PHPStan + +on: + push: + paths: + - '**.php' + - 'phpstan.neon.dist' + - '.github/workflows/phpstan.yml' + +jobs: + phpstan: + name: phpstan + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + + - name: Install composer dependencies + uses: ramsey/composer-install@v3 + + - name: Run PHPStan + run: ./vendor/bin/phpstan --error-format=github diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..c8d94f3 --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,51 @@ +name: run-tests + +on: + push: + paths: + - '**.php' + - '.github/workflows/run-tests.yml' + - 'phpunit.xml.dist' + - 'composer.json' + - 'composer.lock' + +jobs: + test: + runs-on: ${{ matrix.os }} + timeout-minutes: 5 + strategy: + fail-fast: true + matrix: + os: [ ubuntu-latest ] + php: [ 8.3 ] + laravel: [ 11.*, 10.* ] + stability: [ prefer-lowest, prefer-stable ] + + name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo + coverage: none + + - name: Setup problem matchers + run: | + echo "::add-matcher::${{ runner.tool_cache }}/php.json" + echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Install dependencies + run: | + composer require "laravel/framework:${{ matrix.laravel }}" --no-interaction --no-update + composer update --${{ matrix.stability }} --prefer-dist --no-interaction --ansi + + - name: List Installed Dependencies + run: composer show -D + + - name: Execute tests + run: vendor/bin/pest --ci diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml new file mode 100644 index 0000000..7dace8f --- /dev/null +++ b/.github/workflows/update-changelog.yml @@ -0,0 +1,32 @@ +name: "Update Changelog" + +on: + release: + types: [ released ] + +permissions: + contents: write + +jobs: + update: + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: main + + - name: Update Changelog + uses: stefanzweifel/changelog-updater-action@v1 + with: + latest-version: ${{ github.event.release.name }} + release-notes: ${{ github.event.release.body }} + + - name: Commit updated CHANGELOG + uses: stefanzweifel/git-auto-commit-action@v5 + with: + branch: main + commit_message: "docs: update CHANGELOG" + file_pattern: CHANGELOG.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a7f372d --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.idea +.phpunit.cache +build +composer.lock +coverage +docs +phpunit.xml +phpstan.neon +testbench.yaml +vendor +node_modules diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..89c71b2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +All notable changes to `laravel-data-authorization` will be documented in this file. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..c091da2 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Ephort + +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..feda3b9 --- /dev/null +++ b/README.md @@ -0,0 +1,166 @@ +# Add authorization to your [`spatie/laravel-data`](https://github.com/spatie/laravel-data/) objects + +[![Latest Version on Packagist](https://img.shields.io/packagist/v/ephort/laravel-data-authorization.svg?style=flat-square)](https://packagist.org/packages/ephort/laravel-data-authorization) +[![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/ephort/laravel-data-authorization/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/ephort/laravel-data-authorization/actions?query=workflow%3Arun-tests+branch%3Amain) +[![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/ephort/laravel-data-authorization/fix-php-code-style-issues.yml?branch=main&label=code%20style&style=flat-square)](https://github.com/ephort/laravel-data-authorization/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain) +[![Total Downloads](https://img.shields.io/packagist/dt/ephort/laravel-data-authorization.svg?style=flat-square)](https://packagist.org/packages/ephort/laravel-data-authorization) + +This package adds authorization to your [`spatie/laravel-data`](https://github.com/spatie/laravel-data/) objects, which +is very useful if you want to expose data objects to the frontend (e.g. when using Inertia), but still need to check +if the user is allowed to perform certain actions. + +## Installation + +Install the package via composer: + +```shell +composer require ephort/laravel-data-authorization +``` + +## Usage + +This package is intended to be used with [Inertia](https://inertiajs.com/), but does not require it or depend on it. + +To add the authorization checks to your data objects, extend the `DataWithAuthorization` class. +All the methods of the base `Data` class are still available. + +Next, implement the static `getAuthorizations` method, which should return an array containing the +names of the actions that need to be exposed and checked. + +```php +use Ephort\LaravelDataAuthorization\DataWithAuthorization; + +class UserData extends DataWithAuthorization +{ + public function __construct( + public int $id, + public string $name, + ) { + } + + public static function getAuthorizations(): array + { + return [ + 'view', + 'update', + 'delete', + ]; + } +} +``` + +When the data object is transformed, a lazy `authorization` property is appended to the resulting array. + +This property contains a key for each defined policy action and is evaluated by `Gate::allows`. + +```json +{ + "id": 1, + "name": "Taylor Otwell", + "authorization": { + "view": true, + "update": false, + "delete": false + } +} +``` + +### Avoid processing authorizations + +Because the `authorization` property is lazy, we can exclude it from the data object to avoid calling the gate on every +serialization. + +```php +UserData::from($user)->exclude('authorization'); +``` + +Or use the built-in helper method: + +```php +UserData::from($user)->withoutAuthorization(); +``` + +### Note when using custom `from` methods + +When using +a [custom `from` method](https://spatie.be/docs/laravel-data/v4/as-a-data-transfer-object/creating-a-data-object#content-magical-creation), +the pipeline that resolves authorizations is not used. + +This means you must call the static `resolveAuthorizationArray` method manually when instantiating your +data object: + +```php +public static function fromModel(User $user): self +{ + return self::from([ + 'id' => $user->id, + 'name' => $user->name, + 'authorization' => static::resolveAuthorizationArray($user), + ]); +} +``` + +You can also wrap the `authorization` array in a Lazy property if needed: + +```php +Lazy::create(fn () => static::resolveAuthorizationArray($user))->defaultIncluded(); +``` + +## TypeScript support + +Thanks to Spatie, it's very easy to generate TypeScript interfaces from data objects and enums. +Install the [TypeScript Transformer package](https://spatie.be/docs/typescript-transformer) and publish its +configuration file: + +```shell +composer require spatie/laravel-typescript-transformer +php artisan vendor:publish --tag=typescript-transformer-config +``` + +Open `config/typescript-transformer.php` and add the following collector and transformer: + +`Ephort\LaravelDataAuthorization\Collectors\DataAuthorizationTypeScriptCollector::class` must be the first collector. + +```diff +'collectors' => [ ++ Ephort\LaravelDataAuthorization\Collectors\DataAuthorizationTypeScriptCollector::class, + Spatie\TypeScriptTransformer\Collectors\DefaultCollector::class, + Spatie\TypeScriptTransformer\Collectors\EnumCollector::class, +], +``` + +```diff +'transformers' => [ + Spatie\LaravelTypeScriptTransformer\Transformers\SpatieStateTransformer::class, + Spatie\TypeScriptTransformer\Transformers\EnumTransformer::class, + Spatie\TypeScriptTransformer\Transformers\SpatieEnumTransformer::class, + Spatie\LaravelTypeScriptTransformer\Transformers\DtoTransformer::class, ++ Ephort\LaravelDataAuthorization\Transformers\DataAuthorizationTypeScriptTransformer::class, +], +``` + +The above configuration uses a collector provided by this package, which finds data objects that +extend `DataWithAuthorization` and generates typings with their authorizations. This is what powers typed authorization +support. + +## Testing + +```bash +composer test +``` + +## Changelog + +Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. + +## Credits + +The code is primarily copied from the awesome project [Hybridly](https://github.com/hybridly/hybridly) by [Enzo +Innocenzi](https://x.com/enzoinnocenzi), which is a great alternative to [Inertia](https://inertiajs.com/). + +- [Peter Brinck](https://github.com/peterbrinck) +- [All Contributors](../../contributors) + +## License + +The MIT License (MIT). Please see [License File](LICENSE.md) for more information. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..8cabf4e --- /dev/null +++ b/composer.json @@ -0,0 +1,88 @@ +{ + "name": "ephort/laravel-data-authorization", + "description": "Add authorization to your data", + "keywords": [ + "ephort", + "laravel", + "laravel-data-authorization", + "laravel-data" + ], + "homepage": "https://github.com/ephort/laravel-data-authorization", + "license": "MIT", + "authors": [ + { + "name": "Peter Brinck", + "email": "peter@ephort.dk", + "role": "Senior Developer" + } + ], + "require": { + "php": "^8.3", + "illuminate/contracts": "^10.0||^11.0", + "spatie/laravel-data": "^4.6", + "spatie/laravel-package-tools": "^1.16", + "spatie/laravel-typescript-transformer": "^2.4" + }, + "require-dev": { + "larastan/larastan": "^2.9", + "laravel/pint": "^1.14", + "nunomaduro/collision": "^8.1.1||^7.10.0", + "orchestra/testbench": "^9.0.0||^8.22.0", + "pestphp/pest": "^2.34", + "pestphp/pest-plugin-arch": "^2.7", + "pestphp/pest-plugin-laravel": "^2.3", + "phpstan/extension-installer": "^1.3", + "phpstan/phpstan-deprecation-rules": "^1.1", + "phpstan/phpstan-phpunit": "^1.3", + "spatie/laravel-ray": "^1.35" + }, + "autoload": { + "psr-4": { + "Ephort\\LaravelDataAuthorization\\": "src/", + "Ephort\\LaravelDataAuthorization\\Database\\Factories\\": "database/factories/" + } + }, + "autoload-dev": { + "psr-4": { + "Ephort\\LaravelDataAuthorization\\Tests\\": "tests/", + "Workbench\\App\\": "workbench/app/" + } + }, + "scripts": { + "post-autoload-dump": "@composer run prepare", + "clear": "@php vendor/bin/testbench package:purge-laravel-data-authorization --ansi", + "prepare": "@php vendor/bin/testbench package:discover --ansi", + "build": [ + "@composer run prepare", + "@php vendor/bin/testbench workbench:build --ansi" + ], + "start": [ + "Composer\\Config::disableProcessTimeout", + "@composer run build", + "@php vendor/bin/testbench serve" + ], + "analyse": "vendor/bin/phpstan analyse", + "test": "vendor/bin/pest", + "test-coverage": "vendor/bin/pest --coverage", + "format": "vendor/bin/pint" + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "pestphp/pest-plugin": true, + "phpstan/extension-installer": true + } + }, + "extra": { + "laravel": { + "providers": [ + "Ephort\\LaravelDataAuthorization\\LaravelDataAuthorizationServiceProvider" + ], + "aliases": { + "LaravelDataAuthorization": "Ephort\\LaravelDataAuthorization\\Facades\\LaravelDataAuthorization" + } + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..e69de29 diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..06ba698 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,14 @@ +includes: + - phpstan-baseline.neon + +parameters: + level: 8 + paths: + - src + tmpDir: build/phpstan + checkOctaneCompatibility: true + checkModelProperties: true + + ignoreErrors: + - identifier: missingType.iterableValue + - identifier: missingType.generics diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..bd7015e --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,38 @@ + + + + + tests + + + + + + + + + + + + + + + ./src + + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..b5dbc70 --- /dev/null +++ b/pint.json @@ -0,0 +1,9 @@ +{ + "preset": "laravel", + "rules": { + "@PSR12": true, + "concat_space": { + "spacing": "one" + } + } +} diff --git a/src/AuthorizationArrayResolver.php b/src/AuthorizationArrayResolver.php new file mode 100644 index 0000000..b51120b --- /dev/null +++ b/src/AuthorizationArrayResolver.php @@ -0,0 +1,20 @@ +mapWithKeys(function (string $action) use ($model) { + return [$action => Gate::allows($action, $model)]; + }) + ->toArray(); + } +} diff --git a/src/Collectors/DataAuthorizationTypeScriptCollector.php b/src/Collectors/DataAuthorizationTypeScriptCollector.php new file mode 100644 index 0000000..1089bad --- /dev/null +++ b/src/Collectors/DataAuthorizationTypeScriptCollector.php @@ -0,0 +1,23 @@ +isSubclassOf(DataWithAuthorization::class)) { + return null; + } + + $transformer = new DataAuthorizationTypeScriptTransformer($this->config); + + return $transformer->transform($class, $class->getShortName()); + } +} diff --git a/src/Contracts/DataAuthorizationContract.php b/src/Contracts/DataAuthorizationContract.php new file mode 100644 index 0000000..1f263f0 --- /dev/null +++ b/src/Contracts/DataAuthorizationContract.php @@ -0,0 +1,8 @@ +firstThrough(ResolveAuthorizationsPipe::class); + } + + public function withoutAuthorization(): static + { + return $this->excludePermanently('authorization'); + } + + final protected static function resolveAuthorizationArray(Model $model): array + { + return resolve(AuthorizationArrayResolver::class)->resolve($model, static::class); + } +} diff --git a/src/LaravelDataAuthorizationServiceProvider.php b/src/LaravelDataAuthorizationServiceProvider.php new file mode 100644 index 0000000..77a5438 --- /dev/null +++ b/src/LaravelDataAuthorizationServiceProvider.php @@ -0,0 +1,14 @@ +name('laravel-data-authorization'); + } +} diff --git a/src/ResolveAuthorizationsPipe.php b/src/ResolveAuthorizationsPipe.php new file mode 100644 index 0000000..6912b57 --- /dev/null +++ b/src/ResolveAuthorizationsPipe.php @@ -0,0 +1,35 @@ +dataClass; + + if (! is_subclass_of($dataClass, DataWithAuthorization::class)) { + return $properties; + } + + return [ + ...$properties, + 'authorization' => Lazy::create(fn () => $this->resolver->resolve($payload, $dataClass))->defaultIncluded(), + ]; + } +} diff --git a/src/Transformers/DataAuthorizationTypeScriptTransformer.php b/src/Transformers/DataAuthorizationTypeScriptTransformer.php new file mode 100644 index 0000000..bc2ac6f --- /dev/null +++ b/src/Transformers/DataAuthorizationTypeScriptTransformer.php @@ -0,0 +1,37 @@ +getProperties(ReflectionProperty::IS_PUBLIC), + fn (ReflectionProperty $property) => ! $property->isStatic() && $property->getName() !== 'authorization', + ); + + return array_values($properties); + } + + protected function transformExtra(ReflectionClass $class, MissingSymbolsCollection $missingSymbols): string + { + $authorizations = $class->getMethod('getAuthorizations')->invoke(null); + + if (empty($authorizations)) { + return ''; + } + + $type = 'authorization: { '; + foreach ($authorizations as $action) { + $type .= "{$action}: boolean; "; + } + + return "{$type}}\n"; + } +} diff --git a/tests/.pest/snapshots/CollectorTest/it_can_collect_DataWithAuthorization.snap b/tests/.pest/snapshots/CollectorTest/it_can_collect_DataWithAuthorization.snap new file mode 100644 index 0000000..08161d6 --- /dev/null +++ b/tests/.pest/snapshots/CollectorTest/it_can_collect_DataWithAuthorization.snap @@ -0,0 +1,5 @@ +{ +id: number; +text: string; +authorization: { view: boolean; update: boolean; } +} \ No newline at end of file diff --git a/tests/.pest/snapshots/TransformerTest/it_can_transform_a_data_object_with_authorization.snap b/tests/.pest/snapshots/TransformerTest/it_can_transform_a_data_object_with_authorization.snap new file mode 100644 index 0000000..08161d6 --- /dev/null +++ b/tests/.pest/snapshots/TransformerTest/it_can_transform_a_data_object_with_authorization.snap @@ -0,0 +1,5 @@ +{ +id: number; +text: string; +authorization: { view: boolean; update: boolean; } +} \ No newline at end of file diff --git a/tests/.pest/snapshots/TransformerTest/it_does_not_add_authorization_if_data_object_authorizations_is_empty.snap b/tests/.pest/snapshots/TransformerTest/it_does_not_add_authorization_if_data_object_authorizations_is_empty.snap new file mode 100644 index 0000000..27fbd2d --- /dev/null +++ b/tests/.pest/snapshots/TransformerTest/it_does_not_add_authorization_if_data_object_authorizations_is_empty.snap @@ -0,0 +1,4 @@ +{ +id: number; +text: string; +} \ No newline at end of file diff --git a/tests/ArchTest.php b/tests/ArchTest.php new file mode 100644 index 0000000..87fb64c --- /dev/null +++ b/tests/ArchTest.php @@ -0,0 +1,5 @@ +expect(['dd', 'dump', 'ray']) + ->each->not->toBeUsed(); diff --git a/tests/CollectorTest.php b/tests/CollectorTest.php new file mode 100644 index 0000000..1e19726 --- /dev/null +++ b/tests/CollectorTest.php @@ -0,0 +1,34 @@ +transformers([ + DataAuthorizationTypeScriptTransformer::class, + ]) + ); + + $reflection = new ReflectionClass(TestData::class); + $transformedType = $collector->getTransformedType($reflection); + + expect($transformedType->transformed)->toMatchSnapshot() + ->and($transformedType->transformed)->toContain('authorization: { view: boolean; update: boolean; }'); +}); + +it('detects if data object is not DataWithAuthorization', function () { + $collector = new DataAuthorizationTypeScriptCollector( + TypeScriptTransformerConfig::create()->transformers([ + DataAuthorizationTypeScriptTransformer::class, + ]) + ); + + $reflection = new ReflectionClass(TestDataWithoutAuthorization::class); + $transformedType = $collector->getTransformedType($reflection); + + expect($transformedType)->toBeNull(); +}); diff --git a/tests/DataWithAuthorizationTest.php b/tests/DataWithAuthorizationTest.php new file mode 100644 index 0000000..8234224 --- /dev/null +++ b/tests/DataWithAuthorizationTest.php @@ -0,0 +1,122 @@ + 'test', + ]); + + $data = TestData::from($testModel)->toArray(); + + expect($data)->toHaveKey('authorization'); +}); + +it('does not add the authorization array if the data is not a model', function () { + $data = TestData::from([ + 'id' => 1, + 'text' => 'Test', + ])->toArray(); + + expect($data)->not()->toHaveKey('authorization'); +}); + +it('does not add the authorization array if the data object is not DataWithAuthorization', function () { + $testModel = TestModel::create([ + 'text' => 'test', + ]); + + $data = TestDataWithoutAuthorization::from($testModel)->toArray(); + + expect($data)->not()->toHaveKey('authorization'); +}); + +it('does not add the authorization array if the authorization is excluded', function (string $dataClass) { + $testModel = TestModel::create([ + 'text' => 'test', + ]); + + /** @var DataWithAuthorizationAlias $dataClass */ + $data = $dataClass::from($testModel)->exclude('authorization')->toArray(); + + expect($data)->not()->toHaveKey('authorization'); +})->with([ + TestData::class, + TestDataCustomFromMethod::class, +]); + +it('does not add the authorization array if the authorization is excluded (helper method)', function (string $dataClass) { + $testModel = TestModel::create([ + 'text' => 'test', + ]); + + /** @var DataWithAuthorizationAlias $dataClass */ + $data = $dataClass::from($testModel)->withoutAuthorization()->toArray(); + + expect($data)->not()->toHaveKey('authorization'); +})->with([ + TestData::class, + TestDataCustomFromMethod::class, +]); + +it('can authorize through the gate', function () { + Gate::define('view', function (UserModel $user, TestModel $testModel) { + return $user->id === $testModel->id; + }); + + $user = UserModel::create([ + 'name' => 'Test User', + 'email' => 'test@example.com', + ]); + + actingAs($user); + + $testModel = TestModel::create([ + 'text' => 'test', + ]); + + $data = TestData::from($testModel)->toArray(); + + expect($data['authorization']['view'])->toBeTrue() + ->and($data['authorization']['update'])->toBeFalse(); +}); + +it('can authorize through the gate using a custom from method', function () { + Gate::define('view', function (UserModel $user, TestModel $testModel) { + return $user->id === $testModel->id; + }); + + $user = UserModel::create([ + 'name' => 'Test User', + 'email' => 'test@example.com', + ]); + + actingAs($user); + + $testModel = TestModel::create([ + 'text' => 'test', + ]); + + $data = TestDataCustomFromMethod::from($testModel)->toArray(); + + expect($data['authorization']['view'])->toBeTrue() + ->and($data['authorization']['update'])->toBeFalse(); +}); + +it('does not add the authorization array if data object is not a DataWithAuthorization', function () { + $testModel = TestModel::create([ + 'text' => 'test', + ]); + + $data = TestDataWithCustomPipeline::from($testModel)->toArray(); + + expect($data)->not()->toHaveKey('authorization'); +}); diff --git a/tests/Fixtures/TestData.php b/tests/Fixtures/TestData.php new file mode 100644 index 0000000..2a34f3a --- /dev/null +++ b/tests/Fixtures/TestData.php @@ -0,0 +1,22 @@ + $testModel->id, + 'text' => $testModel->text, + 'authorization' => Lazy::create(fn () => static::resolveAuthorizationArray($testModel))->defaultIncluded(), + ]); + } +} diff --git a/tests/Fixtures/TestDataWithCustomPipeline.php b/tests/Fixtures/TestDataWithCustomPipeline.php new file mode 100644 index 0000000..53f2f76 --- /dev/null +++ b/tests/Fixtures/TestDataWithCustomPipeline.php @@ -0,0 +1,21 @@ +firstThrough(ResolveAuthorizationsPipe::class); + } +} diff --git a/tests/Fixtures/TestDataWithEmptyAuthorizations.php b/tests/Fixtures/TestDataWithEmptyAuthorizations.php new file mode 100644 index 0000000..84d177f --- /dev/null +++ b/tests/Fixtures/TestDataWithEmptyAuthorizations.php @@ -0,0 +1,19 @@ +in(__DIR__); diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..9d7d90d --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,56 @@ +setUpDatabase($this->app); + } + + protected function getPackageProviders($app): array + { + return [ + LaravelDataAuthorizationServiceProvider::class, + LaravelDataServiceProvider::class, + ]; + } + + /** + * @param Application $app + */ + public function getEnvironmentSetUp($app): void + { + config()->set('database.default', 'sqlite'); + config()->set('database.connections.sqlite', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + + config()->set('app.key', 'base64:' . base64_encode(random_bytes(32))); + } + + protected function setUpDatabase(Application $app): void + { + $app['db']->connection()->getSchemaBuilder()->create('test_models', function (Blueprint $table) { + $table->increments('id'); + $table->string('text'); + }); + + $app['db']->connection()->getSchemaBuilder()->create('user_models', function (Blueprint $table) { + $table->increments('id'); + $table->string('name'); + $table->string('email'); + }); + } +} diff --git a/tests/TransformerTest.php b/tests/TransformerTest.php new file mode 100644 index 0000000..0a2ef2d --- /dev/null +++ b/tests/TransformerTest.php @@ -0,0 +1,32 @@ +transformer = new DataAuthorizationTypeScriptTransformer( + resolve(TypeScriptTransformerConfig::class) + ); +}); + +it('can transform a data object with authorization', function () { + $type = $this->transformer->transform( + new ReflectionClass(TestData::class), + 'TestData' + ); + + expect($type->transformed)->toMatchSnapshot() + ->and($type->transformed)->toContain('authorization: { view: boolean; update: boolean; }'); +}); + +it('does not add authorization if data object authorizations is empty', function () { + $type = $this->transformer->transform( + new ReflectionClass(TestDataWithEmptyAuthorizations::class), + 'TestData' + ); + + expect($type->transformed)->toMatchSnapshot() + ->and($type->transformed)->not()->toContain('authorization'); +});