From 24218b1c44af8b7acfccdb948994f11e1893a2c0 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Mon, 5 Oct 2026 06:24:38 -0400 Subject: [PATCH] Scope db-console gate abilities and install command The 21 gate abilities and the install command lived in flat, host-owned registries under bare names, where a sibling package or the application claiming the same key silently replaces them. They are now laranail-db-console. and laranail::db-console.install. The old abilities stay defined, warn once and delegate through the gate; db-console:install stays as a hidden forwarder that warns. Stored permission rows keep their old names and still resolve. --- CHANGELOG.md | 29 +++ README.md | 6 +- composer.json | 2 +- docs/configuration.md | 2 +- docs/installation.md | 4 +- docs/tools/api.md | 2 +- docs/tools/commands.md | 8 +- docs/tools/rbac.md | 22 ++ src/Access/Drivers/BuiltinRbacDriver.php | 8 +- src/Access/Drivers/SpatieRbacDriver.php | 8 +- src/Authorization/DBConsolePolicy.php | 17 +- src/Authorization/DeprecatedAbilities.php | 42 ++++ .../Commands/DeprecatedInstallCommand.php | 38 ++++ src/Enums/ConsolePermission.php | 43 +++- src/Models/Permission.php | 5 +- src/Providers/DBConsoleServiceProvider.php | 7 +- src/Services/Access/Authorizer.php | 2 +- tests/Feature/Audit/AuditTrailTest.php | 2 +- tests/Feature/Console/InstallTest.php | 3 +- tests/Feature/NamingConventionTest.php | 205 ++++++++++++++++++ tests/Unit/Enums/EnumsTest.php | 5 +- 21 files changed, 433 insertions(+), 27 deletions(-) create mode 100644 src/Authorization/DeprecatedAbilities.php create mode 100644 src/Console/Commands/DeprecatedInstallCommand.php create mode 100644 tests/Feature/NamingConventionTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a3a51c..c8cad91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `['a', '']`, and the empty string was passed on as a database, user, role, ability or event name. The six `(array)` casts are now `arrayOption()`, which trims and drops empties. +- **`docs/tools/commands.md` documented a `db-console:` alias for every command, and + `docs/tools/api.md` named the API middleware `db-console.api-guard`.** No command declares an + alias, and the middleware is `laranail-db-console.api-guard`. + ### Changed - The base `DBConsoleCommand` applies `laranail/package-tools`' `Commands\Concerns\ReadsOptions`; @@ -29,10 +33,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 method cannot narrow a protected one. Removing it is behaviour-preserving: both return `''` for a non-string value. +- **The gate abilities and the install command are vendor-scoped.** The 21 abilities are + `laranail-db-console.` (was `db-console.`), and the install command is + `laranail::db-console.install` (was `db-console:install`). Both lived in flat, host-owned + registries, where a sibling package or the application claiming the same key silently replaces + it. `ConsolePermission::ability()` returns the scoped name, so the audit trail records it as the + target of a denied action, and `install` seeds permission rows under it. Rows stored before the + rename keep their names and still resolve. Requires `laranail/package-tools ^0.1.3`. + ### Added +- **`ConsolePermission::fromAbility()`** reads a gate ability or stored permission name in either + form, and **`deprecatedAbility()`** names the pre-0.1 ability. Both RBAC drivers read stored names + through `fromAbility()`. +- **`tests/Feature/NamingConventionTest.php`** reads the live gate, Artisan and middleware + registries through package-tools' `AssertsRegisteredNames`, and checks every deprecated alias + answers as its replacement and announces itself once. + - **`assertNoNullOnlyOptionGuards()` is enforced over `src/`.** +### Deprecated + +- **Gate abilities `db-console.` (21).** Each is still defined and asks the gate for its + `laranail-db-console.*` ability, so a host's definition or before/after callback for the scoped + name governs the old one too. The first check of each raises one `E_USER_DEPRECATED` notice. +- **Command `db-console:install`.** Still registered, hidden; it prints one line naming + `laranail::db-console.install`, then runs it and returns its exit code. + +Both are removed no earlier than the next minor after 0.1. + ## [0.1.0] - 2026-07-11 Initial public release. diff --git a/README.md b/README.md index 45e6fed..518879a 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,11 @@ Requires PHP `^8.4.1 || ^8.5` and Laravel `^13.0`. Headless by design: all logic ```bash composer require laranail/db-console -php artisan db-console:install +php artisan laranail::db-console.install ``` +`db-console:install` still works as a deprecated alias: it prints one line naming the replacement and runs it. + The installer publishes the config, runs the catalog migrations, seeds the shipped console roles, assigns the bootstrap Owner (set `DB_CONSOLE_OWNER_USER_ID`), and runs `doctor` to health-check your servers. Point it at a **minimal** admin account, never root: @@ -36,7 +38,7 @@ GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, ALTER, INDEX, EXECUTE, CREAT ### Getting started -1. Set `DB_CONSOLE_OWNER_USER_ID` before running `php artisan db-console:install` (see Install), so the installer can assign the bootstrap Owner. +1. Set `DB_CONSOLE_OWNER_USER_ID` before running `php artisan laranail::db-console.install` (see Install), so the installer can assign the bootstrap Owner. 2. Add a dedicated admin connection named `db_console_admin` to `config/database.php`, using the minimal admin account above. The `primary` server in `config/db-console.php` uses it by default; `DB_CONSOLE_ENGINE` and `DB_CONSOLE_CONNECTION` override the engine and connection name. 3. Check the server before provisioning anything. TLS is mandatory by default, and `doctor` fails on a root-like account: diff --git a/composer.json b/composer.json index 60bd7e7..f6128ae 100644 --- a/composer.json +++ b/composer.json @@ -49,7 +49,7 @@ "illuminate/validation": "^13.0", "laranail/console": "^0.1", "laranail/enumerator": "^0.1", - "laranail/package-tools": "^0.1" + "laranail/package-tools": "^0.1.3" }, "require-dev": { "aws/aws-sdk-php": "^3.0", diff --git a/docs/configuration.md b/docs/configuration.md index 4349b82..20e7d7c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,6 +1,6 @@ # Configuration -Every `laranail.db-console.*` configuration key. Publish the file with `php artisan db-console:install` or `vendor:publish`. +Every `laranail.db-console.*` configuration key. Publish the file with `php artisan laranail::db-console.install` or `vendor:publish`. ## Catalog diff --git a/docs/installation.md b/docs/installation.md index 326b07d..c683565 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -13,9 +13,11 @@ Requirements, install, the minimal admin account, and catalog setup for `laranai ```bash composer require laranail/db-console -php artisan db-console:install +php artisan laranail::db-console.install ``` +The pre-0.1 name `db-console:install` still works as a deprecated alias. It prints one line naming `laranail::db-console.install`, then runs it, and is removed no earlier than the next minor after 0.1. + The install flow publishes the config and language files, runs the catalog migrations, seeds the shipped console roles, assigns the bootstrap Owner, and runs `doctor`. ## The minimal admin account diff --git a/docs/tools/api.md b/docs/tools/api.md index 2117240..b087c88 100644 --- a/docs/tools/api.md +++ b/docs/tools/api.md @@ -4,7 +4,7 @@ An optional, off-by-default HTTP surface over the same services. ## Overview -The REST API is disabled by default (`api.enabled`). When enabled, every route sits behind the `db-console.api-guard` middleware: it enforces the API is enabled, the request is over HTTPS (outside local), the caller is authenticated via the configured guard (`sanctum` or `passport`), and the IP is allow-listed. **Authorization itself happens in the services** — the same Gate as the CLI and UI — so an out-of-scope caller gets a 403 identically. Destructive endpoints require a matching `confirm` field. Exceptions render as secret-free JSON with a meaningful HTTP status. API tokens carry abilities that can never exceed the issuing operator's own permissions. +The REST API is disabled by default (`api.enabled`). When enabled, every route sits behind the `laranail-db-console.api-guard` middleware: it enforces the API is enabled, the request is over HTTPS (outside local), the caller is authenticated via the configured guard (`sanctum` or `passport`), and the IP is allow-listed. **Authorization itself happens in the services** — the same Gate as the CLI and UI — so an out-of-scope caller gets a 403 identically. Destructive endpoints require a matching `confirm` field. Exceptions render as secret-free JSON with a meaningful HTTP status. API tokens carry abilities that can never exceed the issuing operator's own permissions. --- diff --git a/docs/tools/commands.md b/docs/tools/commands.md index 1cf6dc7..9794025 100644 --- a/docs/tools/commands.md +++ b/docs/tools/commands.md @@ -1,10 +1,14 @@ # Commands -The full Artisan surface, each under a namespaced name and a short alias. +The full Artisan surface, each under the namespaced `laranail::db-console.` name. ## Overview -Every command ships as `laranail::db-console.` with a `db-console:` alias. Groups: `db:create|list|drop`, `user:create|list|password|drop|edit`, `grant|revoke|attach|detach`, `wizard`, `reconcile`, `server:add|list|use`, `audit:view|verify`, `secrets:rotate|driver`, `encryption:status`, `role:list|create|assign|revoke`, `access:show|check`, `token:issue`, `webhook:list|add|remove`, plus `doctor` and `db-console:install`. Destructive commands require typed confirmation (or `--force` in CI); `--generate` prints a password once. All accept `--no-interaction` for scripting. +Every command ships as `laranail::db-console.`. Groups: `db:create|list|drop`, `user:create|list|password|drop|edit`, `grant|revoke|attach|detach`, `wizard`, `reconcile`, `server:add|list|use`, `audit:view|verify`, `secrets:rotate|driver`, `encryption:status`, `role:list|create|assign|revoke`, `access:show|check`, `token:issue`, `webhook:list|add|remove`, plus `doctor` and `install`. Destructive commands require typed confirmation (or `--force` in CI); `--generate` prints a password once. All accept `--no-interaction` for scripting. + +## Deprecated alias + +The install command was `db-console:install` until 0.1. That name is still registered, hidden, as a deprecated alias: it prints one line naming `laranail::db-console.install`, then runs it and returns its exit code. It is removed no earlier than the next minor after 0.1. No other command carries a bare alias. --- diff --git a/docs/tools/rbac.md b/docs/tools/rbac.md index b479dc3..2897e3f 100644 --- a/docs/tools/rbac.md +++ b/docs/tools/rbac.md @@ -10,6 +10,28 @@ Every service method authorizes through the same Gate. Access is **deny-by-defau `builtin` stores roles, permissions, and assignments in the catalog. `spatie` delegates role→permission composition to `spatie/laravel-permission` while DBConsole still owns the scope triple. Both drivers return identical verdicts for the same assignment. +## Gate abilities + +Every console permission is a gate ability named `laranail-db-console.`, for example `laranail-db-console.database.view`. Check them from a host policy, a Blade `@can` or a route's `can:` middleware, with the scope as the second argument: + +```php +Gate::allows('laranail-db-console.database.drop', 'server:prod-mysql'); +``` + +```blade +@can('laranail-db-console.database.view') + Databases +@endcan +``` + +`ConsolePermission::DatabaseView->ability()` returns the same string, and is the spelling to prefer in PHP. + +### Deprecated `db-console.*` abilities + +The 21 abilities were named `db-console.` until 0.1. Each is still defined, as a deprecated alias that asks the gate for its scoped ability, so a host's own `Gate::define()` or before/after callback for `laranail-db-console.*` governs the old name too. The first check of each old name raises one `E_USER_DEPRECATED` notice naming its replacement. They are removed no earlier than the next minor after 0.1; rename them in host policies, `@can` directives and middleware. + +Permission rows stored before the rename keep their `db-console.*` names. Both drivers read either form, so a custom role saved before 0.1 keeps its permissions, and `install` seeds the scoped names alongside them. + ## Shipped roles Owner, Admin, Operator, ReadOnly, Auditor are seeded on install; Owner composes to every permission. Assign with `role:assign --user --role --scope`; inspect with `access:show` and dry-run with `access:check`. diff --git a/src/Access/Drivers/BuiltinRbacDriver.php b/src/Access/Drivers/BuiltinRbacDriver.php index 201c7eb..5436c0a 100644 --- a/src/Access/Drivers/BuiltinRbacDriver.php +++ b/src/Access/Drivers/BuiltinRbacDriver.php @@ -152,10 +152,12 @@ private function roleExists(string $role): bool return Role::query()->where('name', $role)->exists(); } + /** + * A stored permission name in either form: `laranail-db-console.x`, or the pre-0.1 + * `db-console.x` a role saved before the rename still carries. + */ private function permissionFromAbility(string $ability): ?ConsolePermission { - $value = str_starts_with($ability, 'db-console.') ? substr($ability, strlen('db-console.')) : $ability; - - return ConsolePermission::tryFrom($value); + return ConsolePermission::fromAbility($ability); } } diff --git a/src/Access/Drivers/SpatieRbacDriver.php b/src/Access/Drivers/SpatieRbacDriver.php index f8e92d5..225729e 100644 --- a/src/Access/Drivers/SpatieRbacDriver.php +++ b/src/Access/Drivers/SpatieRbacDriver.php @@ -150,10 +150,12 @@ private function scopeRef(Scope $scope): ?string }; } + /** + * A stored permission name in either form: `laranail-db-console.x`, or the pre-0.1 + * `db-console.x` a role saved before the rename still carries. + */ private function permissionFromAbility(string $ability): ?ConsolePermission { - $value = str_starts_with($ability, 'db-console.') ? substr($ability, strlen('db-console.')) : $ability; - - return ConsolePermission::tryFrom($value); + return ConsolePermission::fromAbility($ability); } } diff --git a/src/Authorization/DBConsolePolicy.php b/src/Authorization/DBConsolePolicy.php index 56487f7..f02e6ee 100644 --- a/src/Authorization/DBConsolePolicy.php +++ b/src/Authorization/DBConsolePolicy.php @@ -10,13 +10,19 @@ use Simtabi\Laranail\DBConsole\Access\Contracts\AccessManager; /** - * Registers one gate ability per ConsolePermission (db-console.), + * Registers one gate ability per ConsolePermission (laranail-db-console.), * each delegating to the AccessManager for a scope-aware verdict. Wiring the * gate here means the API, CLI, and web UI all enforce identically through * Gate::allows/authorize — the single enforcement surface (section 17). * * The scope is passed as the gate's second argument (a string like * 'server:prod-mysql'); the AccessManager resolves coverage. + * + * The bare db-console. abilities used until 0.1 stay defined as + * deprecated aliases: each announces itself once and asks the gate for the + * scoped ability, so a host's own definition or before/after callback for the + * scoped name governs the bare one too. Removed no earlier than the next minor + * after 0.1. */ final readonly class DBConsolePolicy { @@ -33,6 +39,15 @@ public function register(Gate $gate): void $scope, ), ); + + $gate->define( + $permission->deprecatedAbility(), + static function (?object $user, ?string $scope = null) use ($gate, $permission): bool { + DeprecatedAbilities::announce($permission->deprecatedAbility(), $permission->ability()); + + return $gate->forUser($user)->check($permission->ability(), $scope === null ? [] : [$scope]); + }, + ); } } } diff --git a/src/Authorization/DeprecatedAbilities.php b/src/Authorization/DeprecatedAbilities.php new file mode 100644 index 0000000..8124d59 --- /dev/null +++ b/src/Authorization/DeprecatedAbilities.php @@ -0,0 +1,42 @@ + */ + private static array $announced = []; + + public static function announce(string $ability, string $replacement): void + { + if (isset(self::$announced[$ability])) { + return; + } + + self::$announced[$ability] = true; + + trigger_error(sprintf( + 'laranail/db-console: the gate ability [%s] is deprecated and will be removed no earlier than the next minor after 0.1. Use [%s] instead.', + $ability, + $replacement, + ), E_USER_DEPRECATED); + } + + /** + * Forget which abilities were announced. For test suites; a process announces each name once + * by design. + */ + public static function forgetWarnings(): void + { + self::$announced = []; + } +} diff --git a/src/Console/Commands/DeprecatedInstallCommand.php b/src/Console/Commands/DeprecatedInstallCommand.php new file mode 100644 index 0000000..efb3f57 --- /dev/null +++ b/src/Console/Commands/DeprecatedInstallCommand.php @@ -0,0 +1,38 @@ +warn(sprintf( + 'Deprecated: [%s] is a deprecated alias and will be removed no earlier than the next minor after 0.1. Use [%s] instead.', + $this->getName(), + self::REPLACEMENT, + )); + + return $this->call(self::REPLACEMENT); + } +} diff --git a/src/Enums/ConsolePermission.php b/src/Enums/ConsolePermission.php index b8a5091..a764bc8 100644 --- a/src/Enums/ConsolePermission.php +++ b/src/Enums/ConsolePermission.php @@ -12,7 +12,7 @@ /** * CONSOLE permissions: what an operator may do with the tool. Entirely * distinct from the MANAGED privileges DBConsole grants to database users. - * Gate abilities are the prefixed form from ability(). + * Gate abilities are the prefixed form from ability(): `laranail-db-console.`. */ enum ConsolePermission: string implements Enumerator, Translatable { @@ -82,10 +82,47 @@ enum ConsolePermission: string implements Enumerator, Translatable case SettingsManage = 'settings.manage'; /** - * The gate ability string for this permission. + * The prefix every gate ability carries. + */ + public const string ABILITY_PREFIX = 'laranail-db-console.'; + + /** + * The bare prefix the gate abilities carried until 0.1. Abilities under it are still defined, + * as deprecated aliases that delegate to the scoped ones, and are removed no earlier than the + * next minor after 0.1. Permission names stored under it still resolve (see fromAbility()). + */ + public const string DEPRECATED_ABILITY_PREFIX = 'db-console.'; + + /** + * The permission a gate ability or stored permission name stands for, in either form + * (`laranail-db-console.x`, or the pre-0.1 `db-console.x`), or the bare permission value. + * Null for anything else, including another package's ability. + */ + public static function fromAbility(string $ability): ?self + { + foreach ([self::ABILITY_PREFIX, self::DEPRECATED_ABILITY_PREFIX] as $prefix) { + if (str_starts_with($ability, $prefix)) { + return self::tryFrom(substr($ability, strlen($prefix))); + } + } + + return self::tryFrom($ability); + } + + /** + * The gate ability string for this permission: `laranail-db-console.`. */ public function ability(): string { - return 'db-console.' . $this->value; + return self::ABILITY_PREFIX . $this->value; + } + + /** + * The bare ability this permission was checked by until 0.1: `db-console.`. + * Still defined on the gate as a deprecated alias of ability(). + */ + public function deprecatedAbility(): string + { + return self::DEPRECATED_ABILITY_PREFIX . $this->value; } } diff --git a/src/Models/Permission.php b/src/Models/Permission.php index 11457cb..6028597 100644 --- a/src/Models/Permission.php +++ b/src/Models/Permission.php @@ -5,8 +5,9 @@ namespace Simtabi\Laranail\DBConsole\Models; /** - * A console ability string (db-console.database.create, ...), seeded from the - * fixed ConsolePermission set (builtin driver only). + * A console ability string (laranail-db-console.database.create, ...), seeded from the + * fixed ConsolePermission set (builtin driver only). Rows seeded before 0.1 carry the + * bare db-console.* form and still resolve through ConsolePermission::fromAbility(). * * @property string $name */ diff --git a/src/Providers/DBConsoleServiceProvider.php b/src/Providers/DBConsoleServiceProvider.php index 83dd41e..c851810 100644 --- a/src/Providers/DBConsoleServiceProvider.php +++ b/src/Providers/DBConsoleServiceProvider.php @@ -57,6 +57,7 @@ use Simtabi\Laranail\DBConsole\Enums\RbacDriver as RbacDriverEnum; use Simtabi\Laranail\DBConsole\Secrets\Stores\DatabaseSecretStore; use Simtabi\Laranail\Package\Tools\Providers\PackageServiceProvider; +use Simtabi\Laranail\DBConsole\Console\Commands\DeprecatedInstallCommand; use Simtabi\Laranail\Package\Tools\Support\Definitions\AboutSectionDefinition; use Simtabi\Laranail\Package\Tools\Support\Definitions\InstallCommandDefinition; @@ -101,14 +102,14 @@ public function packageBooted(): void } /** - * The db-console:install flow (scenario A): publish config + lang, run + * The laranail::db-console.install flow (scenario A): publish config + lang, run * migrations, seed the shipped console roles, assign Owner @ global to the * bootstrap operator (DB_CONSOLE_OWNER_USER_ID when set), then run doctor. */ private function installDefinition(): InstallCommandDefinition { return InstallCommandDefinition::make() - ->named('db-console:install') + ->named(DeprecatedInstallCommand::REPLACEMENT) ->publishes('config', 'translations') ->runsMigrations() ->step('Seed console roles', function (InstallCommand $command): void { @@ -168,6 +169,8 @@ private function commandClasses(): array 'RoleListCommand', 'RoleCreateCommand', 'RoleAssignCommand', 'RoleRevokeCommand', 'AccessShowCommand', 'AccessCheckCommand', 'TokenIssueCommand', 'WebhookListCommand', 'WebhookAddCommand', 'WebhookRemoveCommand', + // The pre-0.1 `db-console:install`, forwarding to laranail::db-console.install. + 'DeprecatedInstallCommand', ], ); } diff --git a/src/Services/Access/Authorizer.php b/src/Services/Access/Authorizer.php index 4a1416c..296b2d1 100644 --- a/src/Services/Access/Authorizer.php +++ b/src/Services/Access/Authorizer.php @@ -13,7 +13,7 @@ /** * The single authorization entry point every service method calls. It * delegates to Laravel's Gate against the DBConsole ability - * (db-console.) at a scope, and translates a denial into the + * (laranail-db-console.) at a scope, and translates a denial into the * package's NotAuthorized exception. Every denial is also dispatched as an * AuthorizationDenied event, so "who tried to do what without access" is * audited alongside what succeeded (section 20). diff --git a/tests/Feature/Audit/AuditTrailTest.php b/tests/Feature/Audit/AuditTrailTest.php index de71e91..3c65f01 100644 --- a/tests/Feature/Audit/AuditTrailTest.php +++ b/tests/Feature/Audit/AuditTrailTest.php @@ -109,7 +109,7 @@ $row = AuditLog::query()->first(); expect($row)->not->toBeNull() - ->and($row->target)->toBe('db-console.database.drop') + ->and($row->target)->toBe('laranail-db-console.database.drop') ->and($row->server)->toBe('prod-mysql'); }); diff --git a/tests/Feature/Console/InstallTest.php b/tests/Feature/Console/InstallTest.php index c84a444..2954f5a 100644 --- a/tests/Feature/Console/InstallTest.php +++ b/tests/Feature/Console/InstallTest.php @@ -13,7 +13,8 @@ }); it('registers the install command', function (): void { - expect(array_keys(Artisan::all()))->toContain('db-console:install'); + // The scoped name, plus the pre-0.1 name kept as a deprecated forwarder. + expect(array_keys(Artisan::all()))->toContain('laranail::db-console.install', 'db-console:install'); }); it('seeds the shipped console roles (the install seed step, run directly)', function (): void { diff --git a/tests/Feature/NamingConventionTest.php b/tests/Feature/NamingConventionTest.php new file mode 100644 index 0000000..ee41dca --- /dev/null +++ b/tests/Feature/NamingConventionTest.php @@ -0,0 +1,205 @@ + + */ +function dbConsoleBareAbilities(): array +{ + return array_map( + static fn (ConsolePermission $p): string => 'db-console.' . $p->value, + ConsolePermission::cases(), + ); +} + +/** + * @return list the E_USER_DEPRECATED messages raised while running $callback + */ +function dbConsoleDeprecations(Closure $callback): array +{ + $notices = []; + set_error_handler(function (int $level, string $message) use (&$notices): bool { + $notices[] = $message; + + return true; + }, E_USER_DEPRECATED); + + try { + $callback(); + } finally { + restore_error_handler(); + } + + return $notices; +} + +beforeEach(function (): void { + DeprecatedAbilities::forgetWarnings(); +}); + +it('scopes every gate ability, keeping the bare ones as deprecated aliases', function (): void { + $scoped = $this->assertGateAbilitiesScoped( + dbConsoleScope(), + deprecated: dbConsoleBareAbilities(), + atLeast: 21, + ); + + expect($scoped)->toHaveCount(21) + ->toContain('laranail-db-console.access', 'laranail-db-console.database.drop'); +}); + +it('scopes every command, keeping db-console:install as a deprecated alias', function (): void { + $scoped = $this->assertCommandNamesScoped( + dbConsoleScope(), + deprecated: ['db-console:install'], + atLeast: 34, + ); + + expect($scoped)->toContain('laranail::db-console.install', 'laranail::db-console.doctor'); +}); + +it('scopes the middleware alias', function (): void { + $this->assertRegisteredNamesScoped(NameRegistry::Middleware, dbConsoleScope()); +}); + +function dbConsoleOperators(): array +{ + Schema::create('users', function ($table): void { + $table->increments('id'); + $table->string('name')->nullable(); + $table->timestamps(); + }); + config()->set('laranail.db-console.rbac.user_model', User::class); + + return [User::query()->create(['name' => 'owner']), User::query()->create(['name' => 'stranger'])]; +} + +it('answers a bare ability exactly as its scoped ability', function (): void { + $this->migrateCatalog(); + app(RbacDriver::class)->seedDefaultRoles(); + [$owner, $stranger] = dbConsoleOperators(); + app(RbacDriver::class)->assign($owner, ConsoleRole::Owner->value, 'global'); + + dbConsoleDeprecations(function () use ($owner, $stranger): void { + foreach (ConsolePermission::cases() as $permission) { + foreach ([$owner, $stranger] as $user) { + foreach (['global', 'server:prod-mysql'] as $scope) { + expect(Gate::forUser($user)->allows($permission->deprecatedAbility(), $scope)) + ->toBe(Gate::forUser($user)->allows($permission->ability(), $scope)); + } + } + } + }); + + expect(Gate::forUser($owner)->allows('db-console.database.drop', 'server:prod-mysql'))->toBeTrue() + ->and(Gate::forUser($stranger)->allows('db-console.database.drop', 'server:prod-mysql'))->toBeFalse(); +}); + +it('still resolves a custom role stored with pre-0.1 permission names', function (): void { + $this->migrateCatalog(); + [$operator] = dbConsoleOperators(); + + $legacy = Permission::query()->create(['name' => 'db-console.database.view']); + $scoped = Permission::query()->create(['name' => 'laranail-db-console.audit.view']); + Role::query()->create(['name' => 'legacy-viewer', 'label' => 'Legacy viewer', 'is_shipped' => false]) + ->permissions()->sync([$legacy->id, $scoped->id]); + + expect(app(RbacDriver::class)->permissionsForRole('legacy-viewer')) + ->toEqualCanonicalizing([ConsolePermission::DatabaseView, ConsolePermission::AuditView]); + + app(RbacDriver::class)->assign($operator, 'legacy-viewer', 'global'); + + expect(Gate::forUser($operator)->allows('laranail-db-console.database.view', 'global'))->toBeTrue() + ->and(Gate::forUser($operator)->allows('laranail-db-console.database.drop', 'global'))->toBeFalse(); +}); + +it('reads a permission name in either form, and nothing else', function (): void { + expect(ConsolePermission::fromAbility('laranail-db-console.database.drop'))->toBe(ConsolePermission::DatabaseDrop) + ->and(ConsolePermission::fromAbility('db-console.database.drop'))->toBe(ConsolePermission::DatabaseDrop) + ->and(ConsolePermission::fromAbility('database.drop'))->toBe(ConsolePermission::DatabaseDrop) + ->and(ConsolePermission::fromAbility('other-package.database.drop'))->toBeNull(); +}); + +it('delegates a bare ability through the gate, so a host override of the scoped one applies', function (): void { + Gate::define('laranail-db-console.audit.view', static fn (?object $user = null): bool => true); + + dbConsoleDeprecations(function (): void { + expect(Gate::allows('db-console.audit.view'))->toBeTrue() + ->and(Gate::allows('db-console.audit.view', 'global'))->toBeTrue(); + }); +}); + +it('announces a bare ability once per name', function (): void { + $notices = dbConsoleDeprecations(function (): void { + Gate::allows('db-console.access'); + Gate::allows('db-console.access'); + Gate::allows('db-console.audit.view'); + Gate::allows('laranail-db-console.access'); + }); + + expect($notices)->toHaveCount(2) + ->and($notices[0])->toContain('[db-console.access]')->toContain('[laranail-db-console.access]'); +}); + +it('keeps db-console:install working, warning and forwarding to the scoped command', function (): void { + // Stand in for the real install, which publishes into the host and probes servers. + $canonical = new class extends Command + { + use SupportsNamespacedNames; + + protected $signature = 'laranail::db-console.install'; + + public function handle(): int + { + $this->line('canonical install ran'); + + return self::SUCCESS; + } + }; + app(Kernel::class)->registerCommand($canonical); + + $exit = Artisan::call('db-console:install'); + $output = Artisan::output(); + + expect($exit)->toBe(0) + ->and($output)->toContain('[db-console:install] is a deprecated alias') + ->and($output)->toContain('[laranail::db-console.install]') + ->and($output)->toContain('canonical install ran'); +}); diff --git a/tests/Unit/Enums/EnumsTest.php b/tests/Unit/Enums/EnumsTest.php index 2f6a125..9613ee7 100644 --- a/tests/Unit/Enums/EnumsTest.php +++ b/tests/Unit/Enums/EnumsTest.php @@ -65,8 +65,9 @@ function allDBConsoleEnums(): array }); it('ConsolePermission abilities carry the db-console gate prefix', function (): void { - expect(ConsolePermission::Access->ability())->toBe('db-console.access') - ->and(ConsolePermission::DatabaseDrop->ability())->toBe('db-console.database.drop') + expect(ConsolePermission::Access->ability())->toBe('laranail-db-console.access') + ->and(ConsolePermission::DatabaseDrop->ability())->toBe('laranail-db-console.database.drop') + ->and(ConsolePermission::DatabaseDrop->deprecatedAbility())->toBe('db-console.database.drop') ->and(ConsolePermission::cases())->toHaveCount(21); });