diff --git a/.gitattributes b/.gitattributes index 8fe1ba9..fe80458 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,6 +9,9 @@ /.github export-ignore /build export-ignore /tests export-ignore +/.editorconfig export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore /CHANGELOG.md export-ignore /CODE_OF_CONDUCT.md export-ignore /CONTRIBUTING.md export-ignore @@ -16,5 +19,6 @@ /phpstan-baseline.neon export-ignore /phpunit.xml.dist export-ignore /pint.json export-ignore +/README.md export-ignore /rector.php export-ignore /Roadmap.md export-ignore diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5dace46..7fd1732 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -41,7 +41,7 @@ jobs: echo "cache-key=${{ env.cache_key }}" >> "$GITHUB_OUTPUT" echo "extensions=${{ env.extensions }}" >> "$GITHUB_OUTPUT" echo "tools=${{ env.tools }}" >> "$GITHUB_OUTPUT" - echo "php_versions=['8.1', '8.2']" >> "$GITHUB_OUTPUT" + echo "php_versions=['8.1', '8.2', '8.3']" >> "$GITHUB_OUTPUT" echo "php_version=['8.1']" >> "$GITHUB_OUTPUT" code-style: @@ -69,6 +69,7 @@ jobs: cache_key: ${{ needs.env-setup.outputs.cache-key }} extensions: ${{ needs.env-setup.outputs.extensions }} min_coverage: 100 + type_coverage: 100 rector: needs: [ env-setup ] diff --git a/CHANGELOG.md b/CHANGELOG.md index e0152b8..8bb3d5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# Release Notes for 2.x + +## [2.0.0 (2023-12-17)](https://github.com/Jampire/moonshine-impersonate/compare/v1.4.1...v2.0.0) + +### Adds + +- MoonShine v2 support, Belarus Localization ([#25](https://github.com/Jampire/moonshine-impersonate/pull/25)) + # Release Notes for 1.x ## [1.4.1 (2023-12-15)](https://github.com/Jampire/moonshine-impersonate/compare/v1.4.0...v1.4.1) diff --git a/README.md b/README.md index 8537db6..fb704f2 100644 --- a/README.md +++ b/README.md @@ -19,10 +19,11 @@ saves lots of time because you can see exactly what they see. ## Compatibility -| MoonShine | MoonShine Impersonate | -|:------------------------:|:-----------------------:| -| \>= v1.52 and <= v1.57.4 | <= v1.2.0 | -| >= v1.58.0 | >= v1.3.0 | +| MoonShine | MoonShine Impersonate | Currently supported | +|:------------------------:|:---------------------:|:-------------------:| +| \>= v1.52 and <= v1.57.4 | <= v1.2.0 | no | +| >= v1.58.0 | >= v1.3.0 | no | +| >= v2.0 | >= v2.0 | yes | ## Installation @@ -67,8 +68,8 @@ Please review and abide by the [Code of Conduct][5]. - [Laravel Orchid impersonation mode][10] - [Laravel standalone package][11] -[1]: https://moonshine.cutcode.dev/ -[2]: https://moonshine.cutcode.dev/section/installation +[1]: https://moonshine-laravel.com/ +[2]: https://moonshine-laravel.com/docs/section/installation [3]: https://dzianiskotau.com/moonshine-impersonate/ [4]: CONTRIBUTING.md [5]: CODE_OF_CONDUCT.md diff --git a/Roadmap.md b/Roadmap.md index f78f2af..c1de998 100644 --- a/Roadmap.md +++ b/Roadmap.md @@ -1 +1,2 @@ - implement `database` provider +- implement `Impersonation` resource diff --git a/composer.json b/composer.json index e8f93b6..4a160bf 100644 --- a/composer.json +++ b/composer.json @@ -25,17 +25,19 @@ "source": "https://github.com/Jampire/moonshine-impersonate" }, "require": { - "php": "^8.1|^8.2" + "php": "^8.1|^8.2|^8.3" }, "require-dev": { "driftingly/rector-laravel": "^0.20.0", "laravel/pint": "^1.10", - "moonshine/moonshine": "^1.56", + "moonshine/changelog": "^1.0", + "moonshine/moonshine": "^2.0", "nunomaduro/collision": "^7.5", "nunomaduro/larastan": "^2.0", "orchestra/testbench": "^8.5", "pestphp/pest": "^2.6", "pestphp/pest-plugin-laravel": "^2.0", + "pestphp/pest-plugin-type-coverage": "^2.5", "rector/rector": "^0.16.0" }, "autoload": { @@ -52,7 +54,7 @@ } }, "conflict": { - "moonshine/moonshine": "<1.58.0 || >= 2" + "moonshine/moonshine": "<2" }, "scripts": { "post-autoload-dump": [ @@ -65,10 +67,13 @@ "analyse": "./vendor/bin/phpstan analyse --ansi --memory-limit=-1", "tests": "./vendor/bin/pest --no-coverage --parallel", "tests-coverage": "XDEBUG_MODE=coverage ./vendor/bin/pest --coverage --min=100 --parallel", + "tests-type": "./vendor/bin/pest --type-coverage --min=100 --parallel", "all": [ "@composer validate --strict --ansi", "@style", + "@rector", "@analyse", + "@tests-type", "@tests-coverage" ] }, diff --git a/config/impersonate.php b/config/impersonate.php index b0b440b..f938225 100644 --- a/config/impersonate.php +++ b/config/impersonate.php @@ -12,7 +12,7 @@ 'routes' => [ // impersonate routes prefix - 'prefix' => env('MS_IMPERSONATE_ROUTE_PREFIX', 'impersonate'), + 'prefix' => env('MS_IMPERSONATE_ROUTE_PREFIX', config('moonshine.route.prefix').'/impersonate'), // what middleware is used for routes to enter/stop impersonation 'middleware' => ['web'], @@ -23,10 +23,14 @@ 'icon' => env('MS_IMPERSONATE_ENTER_BUTTON_ICON', 'heroicons.outline.eye') ], 'stop' => [ - // if true the button will be set to the header of the page - 'enabled' => env('MS_IMPERSONATE_STOP_BUTTON_ENABLED', true), 'icon' => env('MS_IMPERSONATE_STOP_BUTTON_ICON', 'heroicons.outline.eye-slash'), - 'class' => env('MS_IMPERSONATE_STOP_BUTTON_CLASS', 'btn-pink'), + 'class' => env('MS_IMPERSONATE_STOP_BUTTON_CLASS', 'btn-secondary'), ], ], + + // query string key name for resource item + 'resource_item_key' => env('MS_IMPERSONATE_RESOURCE_ITEM_KEY', 'resourceItem'), + + // show 'toast' notifications on different actions + 'show_notification' => env('MS_IMPERSONATE_SHOW_NOTIFICATION', true), ]; diff --git a/lang/be/ui.php b/lang/be/ui.php new file mode 100644 index 0000000..a413b19 --- /dev/null +++ b/lang/be/ui.php @@ -0,0 +1,14 @@ + [ + 'enter' => [ + 'label' => 'Падмяніць карыстальніка', + 'message' => 'Вы паспяхова пераключыліся на іншага карыстальніка.', + ], + 'stop' => [ + 'label' => 'Спыніць падмену карыстальніка', + 'message' => 'Падмена карыстальніка была паспяхова спынена!', + ], + ], +]; diff --git a/lang/be/validation.php b/lang/be/validation.php new file mode 100644 index 0000000..2df6d90 --- /dev/null +++ b/lang/be/validation.php @@ -0,0 +1,13 @@ + [ + 'is_impersonating' => 'Вы ўжо ў рэжыме падмены карыстальніка. Спачатку спыніце бягучы рэжым падмены!', + 'cannot_impersonate' => 'У вас няма правоў для падмены іншых карыстальнікаў.', + 'cannot_be_impersonated' => 'Гэты карыстастальнік не можа быць падменены.', + 'id' => 'ID Карыстальніка', + ], + 'stop' => [ + 'is_not_impersonating' => 'Ніякі карыстастальнік не падменены. Няма чаго рабіць.', + ], +]; diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 91268aa..16ff8a8 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -12,6 +12,11 @@ processIsolation="false" stopOnFailure="false" > + + + ./src + + ./tests/Unit diff --git a/resources/views/components/impersonate-stop.blade.php b/resources/views/components/impersonate-stop.blade.php index 28a3eaa..ace0d12 100644 --- a/resources/views/components/impersonate-stop.blade.php +++ b/resources/views/components/impersonate-stop.blade.php @@ -1,3 +1,5 @@ - - {{ $label }} - +@if($canStop) + + {{ $label }} + +@endif diff --git a/resources/views/impersonate/buttons/stop.blade.php b/resources/views/impersonate/buttons/stop.blade.php deleted file mode 100644 index 889444d..0000000 --- a/resources/views/impersonate/buttons/stop.blade.php +++ /dev/null @@ -1 +0,0 @@ - diff --git a/routes/web.php b/routes/web.php index 29f1d3d..5b1f0ff 100644 --- a/routes/web.php +++ b/routes/web.php @@ -5,12 +5,17 @@ use Illuminate\Support\Facades\Route; use Jampire\MoonshineImpersonate\Http\Controllers\ImpersonateController; use Jampire\MoonshineImpersonate\Support\Settings; +use MoonShine\Http\Middleware\ChangeLocale; + +$middlewares = config_impersonate('routes.middleware'); +$middlewares[] = ChangeLocale::class; Route::controller(ImpersonateController::class) ->name(Settings::ALIAS.'.') ->prefix(config_impersonate('routes.prefix')) - ->middleware(config_impersonate('routes.middleware')) + ->middleware($middlewares) ->group(function (): void { - Route::post('/enter', 'enter')->name('enter'); + Route::get('/enter', 'enter')->name('enter'); + Route::post('/enter', 'enter')->name('enter-confirm'); Route::get('/stop', 'stop')->name('stop'); }); diff --git a/src/Actions/EnterAction.php b/src/Actions/EnterAction.php index eba9eba..629adf3 100644 --- a/src/Actions/EnterAction.php +++ b/src/Actions/EnterAction.php @@ -23,7 +23,7 @@ public function __construct( /** * @param int $id ID of the impersonated user */ - public function execute(int $id, bool $shouldValidate = false): bool + public function execute(int $id, bool $shouldValidate = true): bool { $user = $this->manager->findUserById($id); diff --git a/src/Actions/StopAction.php b/src/Actions/StopAction.php index 4982d27..0e2bba4 100644 --- a/src/Actions/StopAction.php +++ b/src/Actions/StopAction.php @@ -26,6 +26,7 @@ public function __construct( public function execute(): bool { $user = $this->manager->getUserFromSession(); + if (!$user instanceof Authenticatable) { return false; } diff --git a/src/Enums/State.php b/src/Enums/State.php index 2770543..dd8dad5 100644 --- a/src/Enums/State.php +++ b/src/Enums/State.php @@ -1,5 +1,7 @@ execute($request->safe()->id)) { - // TODO: flash message + $result = $action->execute(id: $request->safe()->id, shouldValidate: false); + if (!$result) { // @codeCoverageIgnoreStart + toast_if( + condition: config_impersonate('show_notification'), + message: trans_impersonate('validation.enter.cannot_be_impersonated'), + type: 'error' + ); + return redirect()->back(); // @codeCoverageIgnoreEnd } + toast_if( + condition: config_impersonate('show_notification'), + message: trans_impersonate('ui.buttons.enter.message'), + type: 'success' + ); + return redirect(config_impersonate('redirect_to')); } public function stop(StopFormRequest $request, StopAction $action): Redirector|RedirectResponse { - if (!$action->execute()) { - // TODO: flash message + $result = $action->execute(); + if (!$result) { // @codeCoverageIgnoreStart + toast_if( + condition: config_impersonate('show_notification'), + message: trans_impersonate('validation.stop.is_not_impersonating'), + type: 'error' + ); + return redirect()->back(); // @codeCoverageIgnoreEnd } + toast_if( + condition: config_impersonate('show_notification'), + message: trans_impersonate('ui.buttons.stop.message'), + type: 'success' + ); + return redirect(config_impersonate('redirect_to')); } } diff --git a/src/Http/Middleware/ImpersonateMiddleware.php b/src/Http/Middleware/ImpersonateMiddleware.php index 20e0c33..313503c 100644 --- a/src/Http/Middleware/ImpersonateMiddleware.php +++ b/src/Http/Middleware/ImpersonateMiddleware.php @@ -1,5 +1,7 @@ trans_impersonate('validation.enter.id'), ]; } + + protected function prepareForValidation(): void + { + $key = config_impersonate('resource_item_key'); + + if ($this->has($key)) { + $this->merge([ + 'id' => (int)$this->get($key), + ]); + } + } } diff --git a/src/ImpersonateServiceProvider.php b/src/ImpersonateServiceProvider.php index 5f36980..44cbe29 100644 --- a/src/ImpersonateServiceProvider.php +++ b/src/ImpersonateServiceProvider.php @@ -14,7 +14,6 @@ use Jampire\MoonshineImpersonate\Providers\EventServiceProvider; use Jampire\MoonshineImpersonate\Services\ImpersonateManager; use Jampire\MoonshineImpersonate\Support\Settings; -use Jampire\MoonshineImpersonate\UI\View\Components\StopImpersonation; /** * Class ImpersonateServiceProvider @@ -76,7 +75,7 @@ private function registerAuthDriver(): void { $auth = app('auth'); - $auth->extend('session', function (Application $app, $name, array $config) use ($auth): SessionGuard { + $auth->extend('session', function (Application $app, string $name, array $config) use ($auth): SessionGuard { $provider = $auth->createUserProvider($config['provider']); $guard = new SessionGuard($name, $provider, $app['session.store']); @@ -99,18 +98,8 @@ private function registerAuthDriver(): void private function registerViews(): void { - $this->loadViewComponentsAs(Settings::ALIAS, [ - 'stop' => StopImpersonation::class, - ]); - $this->loadViewsFrom(__DIR__.'/../resources/views', Settings::ALIAS); - if (config_impersonate('buttons.stop.enabled') === true) { - config([ - 'moonshine.header' => Settings::ALIAS . '::impersonate.buttons.stop', - ]); - } - if ($this->app->runningUnitTests()) { $this->loadViewsFrom(__DIR__.'/../tests/Stubs/resources/views', 'moonshine'); } @@ -145,28 +134,5 @@ private function publishImpersonateResources(): void 'lang', ] ); - - // Views - $this->publishes( - [ - __DIR__.'/../resources/views/impersonate' => resource_path('views/vendor/impersonate') - ], - [ - Settings::ALIAS, - 'views', - ] - ); - - // Views Components - $this->publishes( - [ - __DIR__.'/../src/UI/View/Components' => app_path('View/Components'), - __DIR__.'/../resources/views/components' => resource_path('views/components'), - ], - [ - Settings::ALIAS, - 'view-components', - ] - ); } } diff --git a/src/Providers/EventServiceProvider.php b/src/Providers/EventServiceProvider.php index f2481b8..5ef443b 100644 --- a/src/Providers/EventServiceProvider.php +++ b/src/Providers/EventServiceProvider.php @@ -1,5 +1,7 @@ $user->getAuthIdentifier(), + Settings::key() => $user->getAuthIdentifier(), Settings::impersonatorSessionKey() => $this->moonshineUser->getAuthIdentifier(), Settings::impersonatorSessionGuardKey() => Settings::moonShineGuard(), ]); @@ -98,7 +98,7 @@ public function saveAuthInSession(Authenticatable $user): void public function clearAuthFromSession(): void { session()->forget([ - config_impersonate('key'), + Settings::key(), Settings::impersonatorSessionKey(), Settings::impersonatorSessionGuardKey(), ]); diff --git a/src/Support/helpers.php b/src/Support/helpers.php index 54983c4..c8830d5 100644 --- a/src/Support/helpers.php +++ b/src/Support/helpers.php @@ -3,6 +3,7 @@ use Illuminate\Contracts\Support\Arrayable; use Illuminate\Contracts\View\View; use Jampire\MoonshineImpersonate\Support\Settings; +use MoonShine\MoonShineUI; if (!function_exists('route_impersonate')) { /** @@ -52,3 +53,17 @@ function view_impersonate(string $key, array|Arrayable $data = [], array $mergeD return view(Settings::ALIAS.'::'.$key, $data, $mergeData); } } + +if (!function_exists('toast_if')) { + /** + * @author Dzianis Kotau + */ + function toast_if(bool $condition, string $message, string $type = 'info'): void + { + if (!$condition) { + return; + } + + MoonShineUI::toast(message: $message, type: $type); + } +} diff --git a/src/UI/ActionButtons/EnterImpersonationActionButton.php b/src/UI/ActionButtons/EnterImpersonationActionButton.php new file mode 100644 index 0000000..70da780 --- /dev/null +++ b/src/UI/ActionButtons/EnterImpersonationActionButton.php @@ -0,0 +1,31 @@ + + */ +final class EnterImpersonationActionButton +{ + public static function resolve(): ActionButton + { + return ActionButton::make( + label: trans_impersonate('ui.buttons.enter.label'), + url: static fn (mixed $data): string => route_impersonate('enter', [ + config_impersonate('resource_item_key') => $data->getKey(), + ]), + ) + ->canSee( + callback: fn (Authenticatable $item): bool => app(EnterAction::class)->manager->canEnter($item), + ) + ->icon(config_impersonate('buttons.enter.icon')); + } +} diff --git a/src/UI/ActionButtons/StopImpersonationActionButton.php b/src/UI/ActionButtons/StopImpersonationActionButton.php new file mode 100644 index 0000000..5423433 --- /dev/null +++ b/src/UI/ActionButtons/StopImpersonationActionButton.php @@ -0,0 +1,28 @@ + + */ +final class StopImpersonationActionButton +{ + public static function resolve(): ActionButton + { + return ActionButton::make( + label: trans_impersonate('ui.buttons.stop.label'), + url: static fn (mixed $data): string => route_impersonate('stop'), + ) + ->canSee( + callback: fn (): bool => app(ImpersonateManager::class)->canStop(), + ) + ->icon(config_impersonate('buttons.stop.icon')); + } +} diff --git a/src/UI/Components/StopImpersonation.php b/src/UI/Components/StopImpersonation.php new file mode 100644 index 0000000..5f6da49 --- /dev/null +++ b/src/UI/Components/StopImpersonation.php @@ -0,0 +1,46 @@ + + * @method static static make(string $route = null, string $label = null, string $icon = null, string $class = null) + */ +final class StopImpersonation extends MoonShineComponent +{ + public function __construct( + private readonly ?string $route = null, + private readonly ?string $label = null, + private readonly ?string $icon = null, + private readonly ?string $class = null, + ) { + // + } + + public function getView(): string + { + return $this->customView ?? Settings::ALIAS.'::components.impersonate-stop'; + } + + /** + * @return array{canStop: bool, route: string, label: string, icon: string, class: string} + */ + protected function viewData(): array + { + return [ + 'canStop' => app(ImpersonateManager::class)->canStop(), + 'route' => $this->route ?? route_impersonate('stop'), + 'label' => $this->label ?? trans_impersonate('ui.buttons.stop.label'), + 'icon' => $this->icon ?? config_impersonate('buttons.stop.icon'), + 'class' => $this->class ?? config_impersonate('buttons.stop.class'), + ]; + } +} diff --git a/src/UI/ItemActions/EnterImpersonationItemAction.php b/src/UI/ItemActions/EnterImpersonationItemAction.php deleted file mode 100644 index b97e894..0000000 --- a/src/UI/ItemActions/EnterImpersonationItemAction.php +++ /dev/null @@ -1,35 +0,0 @@ - - */ -final class EnterImpersonationItemAction implements ItemActionContract -{ - use Makeable; - - public function resolve(): ItemAction - { - $action = app(EnterAction::class); - - return ItemAction::make( - trans_impersonate('ui.buttons.enter.label'), - fn (Authenticatable $item) => $action->execute($item->getAuthIdentifier(), true), - trans_impersonate('ui.buttons.enter.message') // TODO: set message from $action->execute - ) - ->canSee(fn (Authenticatable $item) => $action->manager->canEnter($item)) - ->icon(config_impersonate('buttons.enter.icon')) - ; - } -} diff --git a/src/UI/View/Components/StopImpersonation.php b/src/UI/View/Components/StopImpersonation.php deleted file mode 100644 index f7bac74..0000000 --- a/src/UI/View/Components/StopImpersonation.php +++ /dev/null @@ -1,47 +0,0 @@ - - */ -class StopImpersonation extends Component -{ - public readonly string $route; - - public readonly string $label; - - public readonly string $icon; - - public readonly string $class; - - public function __construct( - string $route = null, - string $label = null, - string $icon = null, - string $class = null, - ) { - $this->route = $route ?? route_impersonate('stop'); - $this->label = $label ?? trans_impersonate('ui.buttons.stop.label'); - $this->icon = $icon ?? config_impersonate('buttons.stop.icon'); - $this->class = $class ?? config_impersonate('buttons.stop.class'); - } - - public function render(): View - { - return view_impersonate('components.impersonate-stop'); - } - - public function shouldRender(): bool - { - return app(ImpersonateManager::class)->canStop(); - } -} diff --git a/tests/Feature/Actions/EnterActionTest.php b/tests/Feature/Actions/EnterActionTest.php index 93e7e54..5b4f627 100644 --- a/tests/Feature/Actions/EnterActionTest.php +++ b/tests/Feature/Actions/EnterActionTest.php @@ -12,6 +12,8 @@ use function Pest\Laravel\actingAs; +uses()->group('actions'); + beforeEach(function (): void { setAuthConfig(); enableMoonShineGuard(); @@ -26,9 +28,9 @@ $action = app(EnterAction::class); - expect($action->execute($user->getKey(), true)) + expect($action->execute($user->getKey())) ->toBeTrue() - ->and($action->execute($user->getKey(), true)) + ->and($action->execute($user->getKey())) ->toBeFalse() ; }); @@ -51,7 +53,7 @@ $action = app(EnterAction::class); - expect($action->execute($user->getKey(), true)) + expect($action->execute($user->getKey())) ->toBeFalse() ; }); diff --git a/tests/Feature/Actions/StopActionTest.php b/tests/Feature/Actions/StopActionTest.php index 7fae898..153ef77 100644 --- a/tests/Feature/Actions/StopActionTest.php +++ b/tests/Feature/Actions/StopActionTest.php @@ -8,6 +8,8 @@ use function Pest\Laravel\actingAs; +uses()->group('actions'); + beforeEach(function (): void { setAuthConfig(); enableMoonShineGuard(); diff --git a/tests/Feature/Http/Datasets.php b/tests/Feature/Http/Datasets.php new file mode 100644 index 0000000..b240cf8 --- /dev/null +++ b/tests/Feature/Http/Datasets.php @@ -0,0 +1,8 @@ + ['get', 'enter'], + 'POST route' => ['post', 'enter-confirm'], +]); diff --git a/tests/Feature/Http/ImpersonateEnterTest.php b/tests/Feature/Http/ImpersonateEnterTest.php index 9d60ecd..6a31456 100644 --- a/tests/Feature/Http/ImpersonateEnterTest.php +++ b/tests/Feature/Http/ImpersonateEnterTest.php @@ -9,14 +9,18 @@ use Jampire\MoonshineImpersonate\Tests\Stubs\Models\User; use function Pest\Laravel\actingAs; -use function Pest\Laravel\post; + +uses()->group('http'); beforeEach(function (): void { setAuthConfig(); enableMoonShineGuard(); }); -test('privileged user can impersonate another user', function (): void { +test('privileged user can impersonate another user', function ( + string $routeFn, + string $routeName, +): void { Event::fake(); $user = User::factory()->create([ @@ -27,9 +31,8 @@ ]); actingAs($moonShineUser, Settings::moonShineGuard()); - $response = post(route_impersonate('enter'), [ - 'id' => $user->id, - ]); + + $response = enterResponse($routeFn, $routeName, $user->id); $response ->assertSessionHasNoErrors() @@ -44,6 +47,11 @@ ->toBe(Settings::moonShineGuard()) ->and(auth(Settings::moonShineGuard())->user()->name) ->toBe($moonShineUser->name) + ->and($session->get('toast')) + ->toMatchArray([ + 'type' => 'success', + 'message' => trans_impersonate('ui.buttons.enter.message'), + ]) ; Event::assertDispatched( @@ -51,75 +59,82 @@ $event->impersonator->getAuthIdentifier() === $moonShineUser->id && $event->impersonated->getAuthIdentifier() === $user->id ); -}); +})->with('enter-routes'); -test('unauthorized user cannot impersonate another user', function (): void { - $response = post(route_impersonate('enter'), [ - 'id' => User::factory()->create()->id, - ]); +test('unauthorized user cannot impersonate another user', function ( + string $routeFn, + string $routeName, +): void { + $response = enterResponse($routeFn, $routeName, User::factory()->create()->id); $response->assertForbidden(); expect(session()->get(config_impersonate('key'))) ->toBeEmpty() ; -}); +})->with('enter-routes'); -test('regular user cannot impersonate another user', function (): void { +test('regular user cannot impersonate another user', function ( + string $routeFn, + string $routeName, +): void { $user = User::factory()->create(); actingAs($user, 'web'); - $response = post(route_impersonate('enter'), [ - 'id' => $user->id, - ]); + + $response = enterResponse($routeFn, $routeName, $user->id); $response->assertForbidden(); expect(session()->get(config_impersonate('key'))) ->toBeEmpty() ; -}); +})->with('enter-routes'); -it('cannot impersonate non-existent user', function (): void { +it('cannot impersonate non-existent user', function ( + string $routeFn, + string $routeName, +): void { $user = User::factory()->create(); $moonShineUser = MoonshineUser::factory()->create(); actingAs($moonShineUser, Settings::moonShineGuard()); - $response = post(route_impersonate('enter'), [ - 'id' => $user->id + 1, - ]); + $response = enterResponse($routeFn, $routeName, $user->id + 1); $response->assertNotFound(); expect(session()->get(config_impersonate('key'))) ->toBeEmpty() ; -}); +})->with('enter-routes'); -it('cannot impersonate if already in impersonation mode', function (): void { +it('cannot impersonate if already in impersonation mode', function ( + string $routeFn, + string $routeName, +): void { $user = User::factory()->create(); $moonShineUser = MoonshineUser::factory()->create(); actingAs($moonShineUser, Settings::moonShineGuard()) ->withSession([Settings::key() => $user->getKey()]); - $response = post(route_impersonate('enter'), [ - 'id' => $user->id, - ]); + $response = enterResponse($routeFn, $routeName, $user->id); $response->assertSessionHasErrors([ 'id' => trans_impersonate('validation.enter.is_impersonating'), ]); -}); +})->with('enter-routes'); -it('cannot impersonate non-impersonated user', function (): void { +it('cannot impersonate non-impersonated user', function ( + string $routeFn, + string $routeName +): void { $user = User::factory()->notImpersonated()->create(); $moonShineUser = MoonshineUser::factory()->create(); actingAs($moonShineUser, Settings::moonShineGuard()); - $response = post(route_impersonate('enter'), [ - 'id' => $user->id, - ]); + + $response = enterResponse($routeFn, $routeName, $user->id); $response->assertSessionHasErrors([ 'id' => trans_impersonate('validation.enter.cannot_be_impersonated'), @@ -128,16 +143,18 @@ expect(session()->get(config_impersonate('key'))) ->toBeEmpty() ; -}); +})->with('enter-routes'); -test('admin cannot impersonate with no permissions', function (): void { +test('admin cannot impersonate with no permissions', function ( + string $routeFn, + string $routeName, +): void { $user = User::factory()->create(); $moonShineUser = MoonshineUser::factory()->cannotImpersonate()->create(); actingAs($moonShineUser, Settings::moonShineGuard()); - $response = post(route_impersonate('enter'), [ - 'id' => $user->id, - ]); + + $response = enterResponse($routeFn, $routeName, $user->id); $response->assertSessionHasErrors([ 'id' => trans_impersonate('validation.enter.cannot_impersonate'), @@ -146,4 +163,4 @@ expect(session()->get(config_impersonate('key'))) ->toBeEmpty() ; -}); +})->with('enter-routes'); diff --git a/tests/Feature/Http/ImpersonateMiddlewareTest.php b/tests/Feature/Http/ImpersonateMiddlewareTest.php index 3539ed8..c279646 100644 --- a/tests/Feature/Http/ImpersonateMiddlewareTest.php +++ b/tests/Feature/Http/ImpersonateMiddlewareTest.php @@ -10,7 +10,8 @@ use function Pest\Laravel\actingAs; use function Pest\Laravel\get; -use function Pest\Laravel\post; + +uses()->group('http'); beforeEach(function (): void { setAuthConfig(); @@ -34,9 +35,10 @@ ; actingAs($moonShineUser, Settings::moonShineGuard()); - post(route_impersonate('enter'), [ - 'id' => $user->id, - ])->assertSessionHasNoErrors(); + get(route_impersonate('enter', [ + config_impersonate('resource_item_key') => $user->id, + ])) + ->assertSessionHasNoErrors(); $response = get(route('test.me')); @@ -54,9 +56,10 @@ $moonShineUser = MoonshineUser::factory()->create(); actingAs($moonShineUser, Settings::moonShineGuard()); - post(route_impersonate('enter'), [ - 'id' => $user->id, - ])->assertSessionHasNoErrors(); + get(route_impersonate('enter', [ + config_impersonate('resource_item_key') => $user->id, + ])) + ->assertSessionHasNoErrors(); Auth::logout(); diff --git a/tests/Feature/Http/ImpersonateStopTest.php b/tests/Feature/Http/ImpersonateStopTest.php index 0738cfd..2a22aad 100644 --- a/tests/Feature/Http/ImpersonateStopTest.php +++ b/tests/Feature/Http/ImpersonateStopTest.php @@ -10,7 +10,8 @@ use function Pest\Laravel\actingAs; use function Pest\Laravel\get; -use function Pest\Laravel\post; + +uses()->group('http'); beforeEach(function (): void { setAuthConfig(); @@ -23,9 +24,9 @@ $moonShineUser = MoonshineUser::factory()->create(); actingAs($moonShineUser, Settings::moonShineGuard()); - post(route_impersonate('enter'), [ - 'id' => $user->id, - ]) + get(route_impersonate('enter', [ + config_impersonate('resource_item_key') => $user->id, + ])) ->assertSessionHasNoErrors() ->assertRedirect('/'); @@ -44,6 +45,11 @@ ->toBeEmpty() ->and(auth()->user()) ->toBeEmpty() + ->and($session->get('toast')) + ->toMatchArray([ + 'type' => 'success', + 'message' => trans_impersonate('ui.buttons.stop.message'), + ]) ; Event::assertDispatched( diff --git a/tests/Feature/UI/ActionButtons/EnterImpersonationActionButtonTest.php b/tests/Feature/UI/ActionButtons/EnterImpersonationActionButtonTest.php new file mode 100644 index 0000000..6842a17 --- /dev/null +++ b/tests/Feature/UI/ActionButtons/EnterImpersonationActionButtonTest.php @@ -0,0 +1,61 @@ +group('ui'); + +it('resolves correct action button class', function (): void { + // don't need to use User model here + config(['auth.providers.users.model' => AuthUser::class]); + + $user = User::factory()->create(); + $moonShineUser = MoonshineUser::factory()->create(); + actingAs($moonShineUser, Settings::moonShineGuard()); + + $actionButton = EnterImpersonationActionButton::resolve(); + + expect($actionButton) + ->toBeInstanceOf(ActionButton::class) + ->and($actionButton->url($user)) + ->toBe(route_impersonate('enter', [ + config_impersonate('resource_item_key') => $user->getKey(), + ])) + ->and($actionButton->iconValue()) + ->toBe(config_impersonate('buttons.enter.icon')) + ->and($actionButton->label()) + ->toBe(trans_impersonate('ui.buttons.enter.label')) + ->and($actionButton->inDropdown()) + ->toBeFalse() + ; +}); + +it('can show action button in in-line mode', function (): void { + $moonShineUser = MoonshineUser::factory()->create(); + actingAs($moonShineUser, Settings::moonShineGuard()); + + $actionButton = EnterImpersonationActionButton::resolve()->showInLine(); + + expect($actionButton->inDropdown()) + ->toBeFalse() + ; +}); + +it('can show action button in dropdown mode', function (): void { + $moonShineUser = MoonshineUser::factory()->create(); + actingAs($moonShineUser, Settings::moonShineGuard()); + + $actionButton = EnterImpersonationActionButton::resolve()->showInDropdown(); + + expect($actionButton->inDropdown()) + ->toBeTrue() + ; +}); diff --git a/tests/Feature/UI/ActionButtons/StopImpersonationActionButtonTest.php b/tests/Feature/UI/ActionButtons/StopImpersonationActionButtonTest.php new file mode 100644 index 0000000..b23f75a --- /dev/null +++ b/tests/Feature/UI/ActionButtons/StopImpersonationActionButtonTest.php @@ -0,0 +1,57 @@ +group('ui'); + +it('resolves correct action button class', function (): void { + // don't need to use User model here + config(['auth.providers.users.model' => AuthUser::class]); + + $moonShineUser = MoonshineUser::factory()->create(); + actingAs($moonShineUser, Settings::moonShineGuard()); + + $actionButton = StopImpersonationActionButton::resolve(); + + expect($actionButton) + ->toBeInstanceOf(ActionButton::class) + ->and($actionButton->url()) + ->toBe(route_impersonate('stop')) + ->and($actionButton->iconValue()) + ->toBe(config_impersonate('buttons.stop.icon')) + ->and($actionButton->label()) + ->toBe(trans_impersonate('ui.buttons.stop.label')) + ->and($actionButton->inDropdown()) + ->toBeFalse() + ; +}); + +it('can show action button in in-line mode', function (): void { + $moonShineUser = MoonshineUser::factory()->create(); + actingAs($moonShineUser, Settings::moonShineGuard()); + + $actionButton = StopImpersonationActionButton::resolve()->showInLine(); + + expect($actionButton->inDropdown()) + ->toBeFalse() + ; +}); + +it('can show action button in dropdown mode', function (): void { + $moonShineUser = MoonshineUser::factory()->create(); + actingAs($moonShineUser, Settings::moonShineGuard()); + + $actionButton = StopImpersonationActionButton::resolve()->showInDropdown(); + + expect($actionButton->inDropdown()) + ->toBeTrue() + ; +}); diff --git a/tests/Feature/UI/View/Components/StopImpersonationTest.php b/tests/Feature/UI/Components/StopImpersonationTest.php similarity index 50% rename from tests/Feature/UI/View/Components/StopImpersonationTest.php rename to tests/Feature/UI/Components/StopImpersonationTest.php index 3117709..b17d625 100644 --- a/tests/Feature/UI/View/Components/StopImpersonationTest.php +++ b/tests/Feature/UI/Components/StopImpersonationTest.php @@ -2,11 +2,18 @@ use Jampire\MoonshineImpersonate\Support\Settings; use Jampire\MoonshineImpersonate\Tests\Stubs\Models\MoonshineUser; -use Jampire\MoonshineImpersonate\UI\View\Components\StopImpersonation; +use Jampire\MoonshineImpersonate\Tests\Stubs\Models\User; +use Jampire\MoonshineImpersonate\UI\Components\StopImpersonation; use function Pest\Laravel\actingAs; +uses()->group('ui'); + it('correctly renders stop button with defaults', function (): void { + $moonShineUser = MoonshineUser::factory()->create(); + actingAs($moonShineUser, Settings::moonShineGuard()) + ->withSession([config_impersonate('key') => 1]); + $view = $this->component(StopImpersonation::class); $view @@ -16,6 +23,10 @@ }); it('correctly renders stop button', function (): void { + $moonShineUser = MoonshineUser::factory()->create(); + actingAs($moonShineUser, Settings::moonShineGuard()) + ->withSession([config_impersonate('key') => 1]); + $view = $this->component(StopImpersonation::class, [ 'label' => 'Label', 'class' => 'btn-red', @@ -27,25 +38,28 @@ ->assertSee('btn-red'); }); -it('renders stop button with permission', function (): void { - $moonShineUser = MoonshineUser::factory()->create(); +it('does not render stop button without permissions', function (): void { + $user = User::factory()->create(); + $moonShineUser = MoonshineUser::factory()->cannotImpersonate()->create(); actingAs($moonShineUser, Settings::moonShineGuard()) - ->withSession([config_impersonate('key') => 1]); + ->withSession([config_impersonate('key') => $user->id]); - $component = new StopImpersonation(); + $view = $this->component(StopImpersonation::class); - expect($component->shouldRender()) - ->toBeTrue() - ; + $view + ->assertDontSee(trans_impersonate('ui.buttons.stop.label')) + ->assertDontSee(route_impersonate('stop')) + ->assertDontSee(config_impersonate('buttons.stop.class')); }); -it('does not render stop button without permissions', function (): void { +it('does not render stop button when no one is impersonated', function (): void { $moonShineUser = MoonshineUser::factory()->create(); actingAs($moonShineUser, Settings::moonShineGuard()); - $component = new StopImpersonation(); + $view = $this->component(StopImpersonation::class); - expect($component->shouldRender()) - ->toBeFalse() - ; + $view + ->assertDontSee(trans_impersonate('ui.buttons.stop.label')) + ->assertDontSee(route_impersonate('stop')) + ->assertDontSee(config_impersonate('buttons.stop.class')); }); diff --git a/tests/Feature/UI/ItemActions/EnterImpersonationItemActionTest.php b/tests/Feature/UI/ItemActions/EnterImpersonationItemActionTest.php deleted file mode 100644 index 32f4021..0000000 --- a/tests/Feature/UI/ItemActions/EnterImpersonationItemActionTest.php +++ /dev/null @@ -1,60 +0,0 @@ - AuthUser::class]); - - $user = User::factory()->create(); - $moonShineUser = MoonshineUser::factory()->create(); - actingAs($moonShineUser, Settings::moonShineGuard()); - - $itemAction = EnterImpersonationItemAction::make()->resolve(); - - expect($itemAction) - ->toBeInstanceOf(ItemAction::class) - ->and($itemAction->callback($user)) - ->toBeTrue() - ->and($itemAction->message()) - ->toBe(trans_impersonate('ui.buttons.enter.message')) - ->and($itemAction->iconValue()) - ->toBe(config_impersonate('buttons.enter.icon')) - ->and($itemAction->label()) - ->toBe(trans_impersonate('ui.buttons.enter.label')) - ->and($itemAction->inDropdown()) - ->toBeTrue() - ; -}); - -it('can show item action in in-line mode', function (): void { - $moonShineUser = MoonshineUser::factory()->create(); - actingAs($moonShineUser, Settings::moonShineGuard()); - - $itemAction = EnterImpersonationItemAction::make()->resolve()->showInLine(); - - expect($itemAction->inDropdown()) - ->toBeFalse() - ; -}); - -it('can show confirmation dialog for this item action', function (): void { - $moonShineUser = MoonshineUser::factory()->create(); - actingAs($moonShineUser, Settings::moonShineGuard()); - - $itemAction = EnterImpersonationItemAction::make()->resolve()->withConfirm(); - - expect($itemAction->isConfirmed()) - ->toBeTrue() - ; -}); diff --git a/tests/Pest.php b/tests/Pest.php index bd45fd8..0f6e092 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -4,11 +4,15 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Route; +use Illuminate\Testing\TestResponse; use Jampire\MoonshineImpersonate\Support\Settings; use Jampire\MoonshineImpersonate\Tests\Stubs\Models\MoonshineUser; use Jampire\MoonshineImpersonate\Tests\Stubs\Models\User; use Jampire\MoonshineImpersonate\Tests\TestCase; +use function Pest\Laravel\get; +use function Pest\Laravel\post; + /* |-------------------------------------------------------------------------- | Test Case @@ -113,3 +117,18 @@ function registerHomePage(): void ->name('me'); }); } + +function enterResponse(string $routeFn, string $routeName, int $id): TestResponse +{ + if ($routeFn === 'get') { + $response = get(route_impersonate($routeName, [ + config_impersonate('resource_item_key') => $id, + ])); + } else { + $response = post(route_impersonate($routeName, [ + config_impersonate('resource_item_key') => $id, + ])); + } + + return $response; +} diff --git a/tests/Stubs/Models/User.php b/tests/Stubs/Models/User.php index 9be670b..997512e 100644 --- a/tests/Stubs/Models/User.php +++ b/tests/Stubs/Models/User.php @@ -9,7 +9,7 @@ use Illuminate\Support\Str; use Jampire\MoonshineImpersonate\Services\Contracts\BeImpersonable; use Jampire\MoonshineImpersonate\Tests\Stubs\Database\Factories\UserFactory; -use MoonShine\Traits\Models\HasMoonShineChangeLog; +use MoonShine\ChangeLog\Traits\HasChangeLog; /** * Class User @@ -19,7 +19,7 @@ class User extends BaseUser implements BeImpersonable { use HasFactory; - use HasMoonShineChangeLog; + use HasChangeLog; protected $table = 'users'; diff --git a/tests/Stubs/resources/views/components/link-button.blade.php b/tests/Stubs/resources/views/components/link-button.blade.php new file mode 100644 index 0000000..09d3385 --- /dev/null +++ b/tests/Stubs/resources/views/components/link-button.blade.php @@ -0,0 +1,14 @@ +@props([ + 'icon' => false, + 'filled' => false, +]) +class(['btn', 'btn-primary' => $filled]) }}> + @if($icon) + + @endif + + {{ $slot }} + diff --git a/tests/Stubs/resources/views/components/link.blade.php b/tests/Stubs/resources/views/components/link.blade.php deleted file mode 100644 index 88c6b31..0000000 --- a/tests/Stubs/resources/views/components/link.blade.php +++ /dev/null @@ -1,12 +0,0 @@ -@props([ - 'icon' => false, - 'filled' => false -]) -class(['btn', 'btn-primary' => $filled]) }}> - - - {{ $slot }} - diff --git a/tests/Unit/HelpersTest.php b/tests/Unit/HelpersTest.php new file mode 100644 index 0000000..0cf5e84 --- /dev/null +++ b/tests/Unit/HelpersTest.php @@ -0,0 +1,52 @@ +toBe(route(Settings::ALIAS.'.enter')) + ; +}); + +test('config_impersonate() function returns config option', function (): void { + expect(config_impersonate('key')) + ->toBe(config(Settings::ALIAS.'.key')) + ; +}); + +test('trans_impersonate() function returns translation', function (): void { + expect(trans_impersonate('ui.buttons.enter.message')) + ->toBe(trans(Settings::ALIAS.'::ui.buttons.enter.message')) + ; +}); + +test('view_impersonate() function returns View', function (): void { + expect(view_impersonate('components.impersonate-stop')::class) + ->toBe(view(Settings::ALIAS.'::components.impersonate-stop')::class) + ; +}); + +test('toast_if() function flashes message in a session', function (): void { + $message = 'hello'; + toast_if(true, $message); + + $session = session(); + expect($session->has('toast')) + ->toBeTrue() + ->and($session->get('toast')) + ->toMatchArray([ + 'type' => 'info', + 'message' => $message, + ]) + ; +}); + +test('toast_if() function does not flash message in a session', function (): void { + toast_if(false, 'hello'); + + expect(session()->has('toast')) + ->toBeFalse() + ; +}); diff --git a/tests/Unit/RouteTest.php b/tests/Unit/RouteTest.php new file mode 100644 index 0000000..160cb9f --- /dev/null +++ b/tests/Unit/RouteTest.php @@ -0,0 +1,35 @@ +group('http'); + +test('GET enter route is correct', function (): void { + $route = route_impersonate('enter', [ + config_impersonate('resource_item_key') => 123, + ]); + + expect(Str::endsWith($route, config('moonshine.route.prefix').'/impersonate/enter?resourceItem=123')) + ->toBeTrue() + ; +}); + +test('POST enter route is correct', function (): void { + $route = route_impersonate('enter-confirm', [ + config_impersonate('resource_item_key') => 123, + ]); + + expect(Str::endsWith($route, config('moonshine.route.prefix').'/impersonate/enter?resourceItem=123')) + ->toBeTrue() + ; +}); + +test('GET stop route is correct', function (): void { + $route = route_impersonate('stop'); + + expect(Str::endsWith($route, config('moonshine.route.prefix').'/impersonate/stop')) + ->toBeTrue() + ; +}); diff --git a/tests/Unit/StrictTypesTest.php b/tests/Unit/StrictTypesTest.php new file mode 100644 index 0000000..f807023 --- /dev/null +++ b/tests/Unit/StrictTypesTest.php @@ -0,0 +1,10 @@ +group('arch', 'strict'); + +arch('strict') + ->expect('Jampire\MoonshineImpersonate') + ->toUseStrictTypes() +;