From 20d94caa33017f85bb10ee54c8a8f9a762071d44 Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Sun, 26 Apr 2026 09:54:57 +0200 Subject: [PATCH] feat: Enhance access management with source tracking and revocation - Added source_id and source_type fields to the Access model to track the origin of access grants. - Implemented source relationship in the Access model for better access management. - Introduced revokeBySource method to delete access entries based on their source. - Updated grantAccess and revokeAccess methods to handle source parameters for more granular control. - Added RevokesAccessOnDelete trait to automatically revoke access when the source model is deleted. - Created SourceAccessesRevoked event to notify when access grants are revoked due to source deletion. - Enhanced tests to cover new source-related functionality and ensure proper behavior during access management. - Updated RolesServiceProvider to support auto-loading migrations based on configuration. - Added migration files for creating roles and access tables, including source columns for existing installations. --- config/roles.php | 17 + ...5_01_01_000001_create_blax_role_tables.php | 90 +++++ ..._01_01_000002_create_blax_access_table.php | 50 +++ ...26_000001_add_source_to_accesses_table.php | 92 +++++ .../create_blax_access_table.php.stub | 36 -- .../create_blax_role_tables.php.stub | 79 ---- src/Events/SourceAccessesRevoked.php | 24 ++ src/Models/Access.php | 43 +++ src/RolesServiceProvider.php | 46 ++- src/Traits/HasAccess.php | 100 ++++- src/Traits/RevokesAccessOnDelete.php | 45 +++ tests/Unit/HasAccessSourceTest.php | 347 ++++++++++++++++++ tests/Unit/HasAccessTest.php | 4 + tests/Unit/HasPermissionsTest.php | 1 + tests/Unit/HasRolesTest.php | 1 + tests/Unit/MigrationAutoLoadTest.php | 104 ++++++ 16 files changed, 926 insertions(+), 153 deletions(-) create mode 100644 database/migrations/2025_01_01_000001_create_blax_role_tables.php create mode 100644 database/migrations/2025_01_01_000002_create_blax_access_table.php create mode 100644 database/migrations/2026_04_26_000001_add_source_to_accesses_table.php delete mode 100644 database/migrations/create_blax_access_table.php.stub delete mode 100644 database/migrations/create_blax_role_tables.php.stub create mode 100644 src/Events/SourceAccessesRevoked.php create mode 100644 src/Traits/RevokesAccessOnDelete.php create mode 100644 tests/Unit/HasAccessSourceTest.php create mode 100644 tests/Unit/MigrationAutoLoadTest.php diff --git a/config/roles.php b/config/roles.php index e98951a..3c400d9 100644 --- a/config/roles.php +++ b/config/roles.php @@ -2,6 +2,23 @@ return [ + /* + * Whether the package should auto-run its migrations. + * + * Default: true — fresh installs work plug-and-play (composer require + + * php artisan migrate). The package's own migrations live in vendor/ and + * are auto-loaded. + * + * Set to false if you have already published migrations to your project's + * database/migrations directory and want to manage the schema yourself. + * If you publish *and* leave this true, Laravel's migrator will see the + * same filenames in both locations and run each migration once — but + * that requires the published filename to match the source filename. If + * you've published with a different timestamp prefix, disable this flag + * to avoid re-runs. + */ + 'run_migrations' => true, + 'models' => [ 'role' => \Blax\Roles\Models\Role::class, 'role_member' => \Blax\Roles\Models\RoleMember::class, diff --git a/database/migrations/2025_01_01_000001_create_blax_role_tables.php b/database/migrations/2025_01_01_000001_create_blax_role_tables.php new file mode 100644 index 0000000..910407d --- /dev/null +++ b/database/migrations/2025_01_01_000001_create_blax_role_tables.php @@ -0,0 +1,90 @@ +id(); + $table->string('slug')->unique(); + $table->string('description')->nullable(); + $table->timestamps(); + }); + } + + if (! Schema::hasTable(config('roles.table_names.permission_member'))) { + Schema::create(config('roles.table_names.permission_member'), function (Blueprint $table) { + $table->id(); + $table->foreignId('permission_id')->constrained('permissions')->onDelete('cascade'); + $table->morphs('member'); + $table->json('context')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + }); + } + + if (! Schema::hasTable(config('roles.table_names.permission_usage'))) { + Schema::create(config('roles.table_names.permission_usage'), function (Blueprint $table) { + $table->id(); + $table->foreignId('permission_id')->constrained('permissions')->onDelete('cascade'); + $table->float('usage', 8)->default(1); + $table->morphs('user'); + $table->json('context')->nullable(); + $table->timestamps(); + }); + } + + if (! Schema::hasTable(config('roles.table_names.roles'))) { + Schema::create(config('roles.table_names.roles'), function (Blueprint $table) { + $table->id(); + $table->foreignId('parent_id') + ->nullable() + ->constrained('roles') + ->onDelete('set null'); + $table->string('name')->nullable(); + $table->string('slug')->unique(); + $table->string('description')->nullable(); + $table->timestamps(); + }); + } + + if (! Schema::hasTable(config('roles.table_names.role_member'))) { + Schema::create(config('roles.table_names.role_member'), function (Blueprint $table) { + $table->id(); + $table->foreignId('role_id')->constrained('roles')->onDelete('cascade'); + $table->morphs('member'); + $table->json('context')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists(config('roles.table_names.role_member')); + Schema::dropIfExists(config('roles.table_names.roles')); + Schema::dropIfExists(config('roles.table_names.permission_usage')); + Schema::dropIfExists(config('roles.table_names.permission_member')); + Schema::dropIfExists(config('roles.table_names.permissions')); + } +}; diff --git a/database/migrations/2025_01_01_000002_create_blax_access_table.php b/database/migrations/2025_01_01_000002_create_blax_access_table.php new file mode 100644 index 0000000..15fb4ce --- /dev/null +++ b/database/migrations/2025_01_01_000002_create_blax_access_table.php @@ -0,0 +1,50 @@ +id(); + $blueprint->morphs('entity'); // Who has the access (User, Role, Permission) + $blueprint->morphs('accessible'); // What they have access to (Lection, Scenario, etc.) + $blueprint->nullableMorphs('source'); // What conferred this access (Subscription, Order, etc.) + $blueprint->json('context')->nullable(); + $blueprint->timestamp('expires_at')->nullable(); + $blueprint->timestamps(); + + // No DB-level unique here. SQL engines treat NULL as distinct, so a + // constraint covering source_* would not enforce idempotency for + // null-source rows. Idempotency is handled at the code level + // (updateOrCreate keyed on entity + accessible + source). + $blueprint->index(['entity_type', 'entity_id', 'accessible_type', 'accessible_id'], 'access_lookup'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists(config('roles.table_names.accesses', 'accesses')); + } +}; diff --git a/database/migrations/2026_04_26_000001_add_source_to_accesses_table.php b/database/migrations/2026_04_26_000001_add_source_to_accesses_table.php new file mode 100644 index 0000000..4ed9b46 --- /dev/null +++ b/database/migrations/2026_04_26_000001_add_source_to_accesses_table.php @@ -0,0 +1,92 @@ +dropUnique('access_unique'); + }); + } catch (\Throwable $e) { + // index doesn't exist — fine. + } + + if (! Schema::hasColumn($table, 'source_id')) { + Schema::table($table, function (Blueprint $blueprint) { + $blueprint->nullableMorphs('source'); + }); + } + + try { + Schema::table($table, function (Blueprint $blueprint) { + $blueprint->index( + ['entity_type', 'entity_id', 'accessible_type', 'accessible_id'], + 'access_lookup' + ); + }); + } catch (\Throwable $e) { + // index already exists — fine. + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $table = config('roles.table_names.accesses', 'accesses'); + + try { + Schema::table($table, function (Blueprint $blueprint) { + $blueprint->dropIndex('access_lookup'); + }); + } catch (\Throwable $e) { + // ignore + } + + if (Schema::hasColumn($table, 'source_id')) { + Schema::table($table, function (Blueprint $blueprint) { + $blueprint->dropMorphs('source'); + }); + } + + try { + Schema::table($table, function (Blueprint $blueprint) { + $blueprint->unique( + ['entity_type', 'entity_id', 'accessible_type', 'accessible_id'], + 'access_unique' + ); + }); + } catch (\Throwable $e) { + // ignore + } + } +}; diff --git a/database/migrations/create_blax_access_table.php.stub b/database/migrations/create_blax_access_table.php.stub deleted file mode 100644 index 78b474f..0000000 --- a/database/migrations/create_blax_access_table.php.stub +++ /dev/null @@ -1,36 +0,0 @@ -id(); - $table->morphs('entity'); // Who has the access (User, Role, Permission) - $table->morphs('accessible'); // What they have access to (Lection, Scenario, etc.) - $table->json('context')->nullable(); - $table->timestamp('expires_at')->nullable(); - $table->timestamps(); - - // Prevent duplicate access entries - $table->unique(['entity_type', 'entity_id', 'accessible_type', 'accessible_id'], 'access_unique'); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists(config('roles.table_names.accesses', 'accesses')); - } -}; diff --git a/database/migrations/create_blax_role_tables.php.stub b/database/migrations/create_blax_role_tables.php.stub deleted file mode 100644 index 7ccf7dc..0000000 --- a/database/migrations/create_blax_role_tables.php.stub +++ /dev/null @@ -1,79 +0,0 @@ -id(); - $table->string('slug')->unique(); - $table->string('description')->nullable(); - $table->timestamps(); - }); - - // PermissionMember - Schema::create(config('roles.table_names.permission_member'), function (Blueprint $table) { - $table->id(); - $table->foreignId('permission_id')->constrained('permissions')->onDelete('cascade'); - $table->morphs('member'); - $table->json('context')->nullable(); - $table->timestamp('expires_at')->nullable(); - $table->timestamps(); - }); - - // PermissionUsage - Schema::create(config('roles.table_names.permission_usage'), function (Blueprint $table) { - $table->id(); - $table->foreignId('permission_id')->constrained('permissions')->onDelete('cascade'); - $table->float('usage', 8)->default(1); - $table->morphs('user'); - $table->json('context')->nullable(); - $table->timestamps(); - }); - - // Role - Schema::create(config('roles.table_names.roles'), function (Blueprint $table) { - $table->id(); - $table->foreignId('parent_id') - ->nullable() - ->constrained('roles') - ->onDelete('set null'); - $table->string('name')->nullable(); - $table->string('slug')->unique(); - $table->string('description')->nullable(); - $table->timestamps(); - }); - - // RoleMember - Schema::create(config('roles.table_names.role_member'), function (Blueprint $table) { - $table->id(); - $table->foreignId('role_id')->constrained('roles')->onDelete('cascade'); - $table->morphs('member'); - $table->json('context')->nullable(); - $table->timestamp('expires_at')->nullable(); - $table->timestamps(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists(config('roles.table_names.role_members')); - Schema::dropIfExists(config('roles.table_names.roles')); - Schema::dropIfExists(config('roles.table_names.permission_usage')); - Schema::dropIfExists(config('roles.table_names.permission_member')); - Schema::dropIfExists(config('roles.table_names.permissions')); - } -}; diff --git a/src/Events/SourceAccessesRevoked.php b/src/Events/SourceAccessesRevoked.php new file mode 100644 index 0000000..9652787 --- /dev/null +++ b/src/Events/SourceAccessesRevoked.php @@ -0,0 +1,24 @@ +morphTo(); } + /** + * The model that conferred this access (e.g. Subscription, Order, ProductPrice). + * Null for manual / lifetime grants. + */ + public function source() + { + return $this->morphTo(); + } + /** * Scope to only active (non-expired) access entries. */ @@ -63,4 +76,34 @@ class Access extends Model { return $query->where('expires_at', '<=', now()); } + + /** + * Scope to entries conferred by a specific source model. + */ + public function scopeFromSource($query, Model $source) + { + return $query + ->where('source_type', $source->getMorphClass()) + ->where('source_id', $source->getKey()); + } + + /** + * Delete every access conferred by the given source model. + * + * Use this from an observer / trait / queued listener when the source + * (subscription, order, role membership, …) goes away. Fires the + * SourceAccessesRevoked event so other listeners can react. + * + * @return int Number of deleted access entries + */ + public static function revokeBySource(Model $source): int + { + $count = static::query()->fromSource($source)->delete(); + + if ($count > 0) { + event(new SourceAccessesRevoked($source, $count)); + } + + return $count; + } } diff --git a/src/RolesServiceProvider.php b/src/RolesServiceProvider.php index 99c80c9..4e6599c 100644 --- a/src/RolesServiceProvider.php +++ b/src/RolesServiceProvider.php @@ -26,9 +26,25 @@ class RolesServiceProvider extends \Illuminate\Support\ServiceProvider { $this->offerPublishing(); + $this->registerMigrations(); + $this->registerModelBindings(); } + /** + * Auto-load the package's migrations so fresh installs work without + * publishing. Disabled via `roles.run_migrations = false` for projects + * that prefer to publish + manage migrations themselves. + */ + protected function registerMigrations(): void + { + if (! config('roles.run_migrations', true)) { + return; + } + + $this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); + } + /** * Set up the publishing of configuration files. * @@ -46,25 +62,17 @@ class RolesServiceProvider extends \Illuminate\Support\ServiceProvider __DIR__ . '/../config/roles.php' => $this->app->configPath('roles.php'), ], 'roles-config'); - $this->publishes([ - __DIR__ . '/../database/migrations/create_blax_role_tables.php.stub' => $this->getMigrationFileName('create_blax_role_tables.php'), - __DIR__ . '/../database/migrations/create_blax_access_table.php.stub' => $this->getMigrationFileName('create_blax_access_table.php'), - ], 'roles-migrations'); - } - - /** - * Returns existing migration file if found, else uses the current timestamp. - */ - protected function getMigrationFileName(string $migrationFileName): string - { - $timestamp = date('Y_m_d_His'); - - $filesystem = $this->app->make(\Illuminate\Filesystem\Filesystem::class); - - return \Illuminate\Support\Collection::make([$this->app->databasePath() . DIRECTORY_SEPARATOR . 'migrations' . DIRECTORY_SEPARATOR]) - ->flatMap(fn($path) => $filesystem->glob($path . '*_' . $migrationFileName)) - ->push($this->app->databasePath() . "/migrations/{$timestamp}_{$migrationFileName}") - ->first(); + // Publish migrations to the host project keeping the same filename as + // the source file. That filename is what Laravel's migrator records in + // the `migrations` table, so any migration that has already run via + // auto-load will be marked as run for the published copy too — no + // duplicate execution. + $migrationsPath = __DIR__ . '/../database/migrations'; + $publishMap = []; + foreach (glob($migrationsPath . '/*.php') as $sourcePath) { + $publishMap[$sourcePath] = $this->app->databasePath('migrations/' . basename($sourcePath)); + } + $this->publishes($publishMap, 'roles-migrations'); } protected function registerModelBindings(): void diff --git a/src/Traits/HasAccess.php b/src/Traits/HasAccess.php index 950c68c..6a2bf9e 100644 --- a/src/Traits/HasAccess.php +++ b/src/Traits/HasAccess.php @@ -78,17 +78,27 @@ trait HasAccess /** * Grant this entity access to a specific model. * - * Uses updateOrCreate so that re-granting access (e.g., after a renewal - * purchase) refreshes the expires_at and context even when an existing - * (possibly expired) record already exists. + * Idempotent per (entity, accessible, source) tuple — re-granting with the + * same source refreshes `expires_at` and `context`. Different sources for + * the same accessible coexist as separate rows, so the holder doesn't lose + * access when one source goes away (e.g. a subscription ends but a lifetime + * purchase remains). * * @param Model $accessible The target model * @param array|null $context Optional JSON context * @param Carbon|null $expiresAt Optional expiration + * @param Model|null $source Optional source model (Subscription, Order, …) + * that conferred this access. When the source + * is removed (see RevokesAccessOnDelete or + * Access::revokeBySource), this row is cleaned up. * @return Model The created or updated Access entry */ - public function grantAccess(Model $accessible, ?array $context = null, ?Carbon $expiresAt = null): Model - { + public function grantAccess( + Model $accessible, + ?array $context = null, + ?Carbon $expiresAt = null, + ?Model $source = null, + ): Model { $accessModel = config('roles.models.access'); return $accessModel::updateOrCreate([ @@ -96,6 +106,8 @@ trait HasAccess 'entity_id' => $this->getKey(), 'accessible_type' => $accessible->getMorphClass(), 'accessible_id' => $accessible->getKey(), + 'source_type' => $source?->getMorphClass(), + 'source_id' => $source?->getKey(), ], [ 'context' => $context, 'expires_at' => $expiresAt, @@ -105,27 +117,41 @@ trait HasAccess /** * Revoke this entity's access to a specific model. * + * If $source is provided, only the row matching that exact source is + * removed; other source-bound or null-source grants for the same + * accessible are left intact. Pass $source = null (default) to revoke + * every grant for this (entity, accessible). + * * @param string|Model $accessible Model instance or class name * @param int|string|null $id Required when $accessible is a class name + * @param Model|null $source Optional source model to scope the revoke to * @return int Number of deleted access entries */ - public function revokeAccess(string|Model $accessible, int|string|null $id = null): int + public function revokeAccess(string|Model $accessible, int|string|null $id = null, ?Model $source = null): int { [$accessibleType, $accessibleId] = $this->resolveAccessibleArguments($accessible, $id); - return $this->accesses() + $query = $this->accesses() ->where('accessible_type', $accessibleType) - ->where('accessible_id', $accessibleId) - ->delete(); + ->where('accessible_id', $accessibleId); + + if ($source !== null) { + $query->where('source_type', $source->getMorphClass()) + ->where('source_id', $source->getKey()); + } + + return $query->delete(); } /** - * Revoke all direct accesses for this entity, optionally filtered by accessible type. + * Revoke all direct accesses for this entity, optionally filtered by + * accessible type and/or source. * * @param string|null $accessibleType Optional model class to filter by + * @param Model|null $source Optional source model to filter by * @return int Number of deleted access entries */ - public function revokeAllAccess(?string $accessibleType = null): int + public function revokeAllAccess(?string $accessibleType = null, ?Model $source = null): int { $query = $this->accesses(); @@ -134,9 +160,27 @@ trait HasAccess $query->where('accessible_type', $morphClass); } + if ($source !== null) { + $query->where('source_type', $source->getMorphClass()) + ->where('source_id', $source->getKey()); + } + return $query->delete(); } + /** + * Revoke every direct access on this entity that was conferred by the + * given source. Useful when a single subscription should be torn down + * while leaving lifetime / manual grants alone. + */ + public function revokeAccessBySource(Model $source): int + { + return $this->accesses() + ->where('source_type', $source->getMorphClass()) + ->where('source_id', $source->getKey()) + ->delete(); + } + /** * Get all active Access entries this entity can access (direct + roles + permissions). * @@ -205,24 +249,40 @@ trait HasAccess * Replaces all direct accesses for the given type with the new set. * Only affects accesses owned by THIS entity (not role/permission inherited ones). * + * If $source is provided, the sync is scoped to that source — only rows + * with matching source_type/source_id are removed/replaced, leaving rows + * from other sources (or null source) untouched. This lets a subscription + * refresh its grants without clobbering a user's lifetime purchases. + * * @param string $accessibleType The model class * @param array $ids Array of model IDs to sync * @param array|null $context Optional context for new entries * @param Carbon|null $expiresAt Optional expiration for new entries + * @param Model|null $source Optional source scoping */ - public function syncAccess(string $accessibleType, array $ids, ?array $context = null, ?Carbon $expiresAt = null): void - { + public function syncAccess( + string $accessibleType, + array $ids, + ?array $context = null, + ?Carbon $expiresAt = null, + ?Model $source = null, + ): void { $morphClass = (new $accessibleType)->getMorphClass(); - // Remove accesses not in the new set - $this->accesses() + $scoped = fn($q) => $source + ? $q->where('source_type', $source->getMorphClass()) + ->where('source_id', $source->getKey()) + : $q->whereNull('source_type')->whereNull('source_id'); + + // Remove accesses not in the new set (within this source scope) + $scoped($this->accesses() ->where('accessible_type', $morphClass) - ->whereNotIn('accessible_id', $ids) + ->whereNotIn('accessible_id', $ids)) ->delete(); - // Add missing accesses - $existing = $this->accesses() - ->where('accessible_type', $morphClass) + // Add missing accesses (within this source scope) + $existing = $scoped($this->accesses() + ->where('accessible_type', $morphClass)) ->pluck('accessible_id') ->toArray(); @@ -232,6 +292,8 @@ trait HasAccess $this->accesses()->create([ 'accessible_type' => $morphClass, 'accessible_id' => $id, + 'source_type' => $source?->getMorphClass(), + 'source_id' => $source?->getKey(), 'context' => $context, 'expires_at' => $expiresAt, ]); diff --git a/src/Traits/RevokesAccessOnDelete.php b/src/Traits/RevokesAccessOnDelete.php new file mode 100644 index 0000000..f2ce39e --- /dev/null +++ b/src/Traits/RevokesAccessOnDelete.php @@ -0,0 +1,45 @@ +isForceDeleting() + && ! $model->revokesAccessOnSoftDelete() + ) { + return; + } + + config('roles.models.access', \Blax\Roles\Models\Access::class)::revokeBySource($model); + }); + } + + /** + * Whether revoking should also fire on soft-delete. + * Defaults to false (only hard deletes cascade) — soft-deleted sources + * may legitimately come back. + */ + public function revokesAccessOnSoftDelete(): bool + { + return false; + } +} diff --git a/tests/Unit/HasAccessSourceTest.php b/tests/Unit/HasAccessSourceTest.php new file mode 100644 index 0000000..647a809 --- /dev/null +++ b/tests/Unit/HasAccessSourceTest.php @@ -0,0 +1,347 @@ +set('database.default', 'testing'); + $app['config']->set('database.connections.testing', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + // Tests use workbench-specific UUID-aware migrations; disable the + // package's auto-load so the same tables aren't created twice. + $app['config']->set('roles.run_migrations', false); + } + + protected function defineDatabaseMigrations(): void + { + $this->loadMigrationsFrom(__DIR__ . '/../../workbench/database/migrations'); + } + + // ─── grantAccess with source ───────────────────────────────── + + public function test_grant_access_records_source(): void + { + $user = User::factory()->create(); + $article = Article::create(['title' => 'A']); + $sub = Subscription::create(['name' => 'monthly']); + + $access = $user->grantAccess($article, null, null, $sub); + + $this->assertEquals($sub->getMorphClass(), $access->source_type); + $this->assertEquals($sub->id, $access->source_id); + } + + public function test_grant_access_without_source_leaves_source_null(): void + { + $user = User::factory()->create(); + $article = Article::create(['title' => 'Lifetime']); + + $access = $user->grantAccess($article); + + $this->assertNull($access->source_type); + $this->assertNull($access->source_id); + } + + public function test_grant_access_source_relationship_resolves(): void + { + $user = User::factory()->create(); + $article = Article::create(['title' => 'A']); + $sub = Subscription::create(['name' => 'm']); + + $access = $user->grantAccess($article, null, null, $sub); + + $this->assertInstanceOf(Subscription::class, $access->source); + $this->assertEquals($sub->id, $access->source->id); + } + + // ─── multiple sources for the same accessible coexist ──────── + + public function test_lifetime_and_subscription_grants_for_same_accessible_coexist(): void + { + $user = User::factory()->create(); + $article = Article::create(['title' => 'Both']); + $sub = Subscription::create(['name' => 'pro']); + + $lifetime = $user->grantAccess($article); // null source + $subbed = $user->grantAccess($article, null, now()->addDays(30), $sub); + + $this->assertNotEquals($lifetime->id, $subbed->id); + $this->assertEquals(2, $user->accesses()->count()); + } + + public function test_two_subscription_sources_for_same_accessible_create_two_rows(): void + { + $user = User::factory()->create(); + $article = Article::create(['title' => 'Overlap']); + $sub1 = Subscription::create(['name' => 's1']); + $sub2 = Subscription::create(['name' => 's2']); + + $a1 = $user->grantAccess($article, null, now()->addDays(30), $sub1); + $a2 = $user->grantAccess($article, null, now()->addDays(30), $sub2); + + $this->assertNotEquals($a1->id, $a2->id); + $this->assertEquals(2, $user->accesses()->count()); + } + + // ─── renewal idempotency ───────────────────────────────────── + + public function test_renewing_same_source_updates_existing_row(): void + { + $user = User::factory()->create(); + $article = Article::create(['title' => 'Renew']); + $sub = Subscription::create(['name' => 'm']); + + $first = $user->grantAccess($article, null, now()->addDays(30), $sub); + $second = $user->grantAccess($article, null, now()->addDays(60), $sub); + + $this->assertEquals($first->id, $second->id); + $this->assertEquals(1, $user->accesses()->count()); + $this->assertTrue($second->expires_at->greaterThan($first->expires_at->copy()->addDays(20))); + } + + public function test_renewal_after_expiry_reactivates_grant(): void + { + $user = User::factory()->create(); + $article = Article::create(['title' => 'Reactivate']); + $sub = Subscription::create(['name' => 'm']); + + $user->grantAccess($article, null, Carbon::now()->subDay(), $sub); + $this->assertFalse($user->hasAccess($article)); + + $user->grantAccess($article, null, Carbon::now()->addDays(30), $sub); + $this->assertTrue($user->hasAccess($article)); + $this->assertEquals(1, $user->accesses()->count()); + } + + // ─── source cascade cleanup ────────────────────────────────── + + public function test_deleting_source_revokes_subscription_grants_only(): void + { + $user = User::factory()->create(); + $articleA = Article::create(['title' => 'A']); + $articleB = Article::create(['title' => 'B']); + $sub = Subscription::create(['name' => 'm']); + + $user->grantAccess($articleA); // lifetime — survives + $user->grantAccess($articleA, null, now()->addDays(30), $sub); // subbed + $user->grantAccess($articleB, null, now()->addDays(30), $sub); // subbed + + $sub->delete(); + + $this->assertTrue($user->hasAccess($articleA), 'lifetime grant must survive'); + $this->assertFalse($user->hasAccess($articleB), 'subscription-only grant must be revoked'); + $this->assertEquals(1, $user->accesses()->count()); + } + + public function test_cascade_does_not_touch_other_subscriptions(): void + { + $user = User::factory()->create(); + $article = Article::create(['title' => 'shared']); + $subA = Subscription::create(['name' => 'a']); + $subB = Subscription::create(['name' => 'b']); + + $user->grantAccess($article, null, now()->addDays(30), $subA); + $user->grantAccess($article, null, now()->addDays(30), $subB); + + $subA->delete(); + + $this->assertTrue($user->hasAccess($article)); + $this->assertEquals(1, $user->accesses()->count()); + } + + public function test_cascade_dispatches_event(): void + { + Event::fake([SourceAccessesRevoked::class]); + + $user = User::factory()->create(); + $article = Article::create(['title' => 'evt']); + $sub = Subscription::create(['name' => 'm']); + + $user->grantAccess($article, null, now()->addDays(30), $sub); + + $sub->delete(); + + Event::assertDispatched( + SourceAccessesRevoked::class, + fn(SourceAccessesRevoked $e) => $e->source->is($sub) && $e->count === 1, + ); + } + + public function test_cascade_event_not_dispatched_when_no_rows_match(): void + { + Event::fake([SourceAccessesRevoked::class]); + + $sub = Subscription::create(['name' => 'unused']); + $sub->delete(); + + Event::assertNotDispatched(SourceAccessesRevoked::class); + } + + // ─── static helper / observer / trait parity ───────────────── + + public function test_static_revoke_by_source_helper(): void + { + $user = User::factory()->create(); + $article = Article::create(['title' => 'S']); + $sub = Subscription::create(['name' => 'm']); + + $user->grantAccess($article, null, now()->addDays(30), $sub); + + $deleted = Access::revokeBySource($sub); + + $this->assertEquals(1, $deleted); + $this->assertFalse($user->hasAccess($article)); + } + + // ─── revokeAccess scoped by source ─────────────────────────── + + public function test_revoke_access_with_source_filter_only_removes_that_source(): void + { + $user = User::factory()->create(); + $article = Article::create(['title' => 'Targeted']); + $sub = Subscription::create(['name' => 'm']); + + $user->grantAccess($article); // lifetime + $user->grantAccess($article, null, now()->addDays(30), $sub); + + $user->revokeAccess($article, null, $sub); + + $this->assertTrue($user->hasAccess($article)); + $this->assertEquals(1, $user->accesses()->count()); + } + + public function test_revoke_access_without_source_filter_removes_all_for_accessible(): void + { + $user = User::factory()->create(); + $article = Article::create(['title' => 'AllOfIt']); + $sub = Subscription::create(['name' => 'm']); + + $user->grantAccess($article); + $user->grantAccess($article, null, now()->addDays(30), $sub); + + $deleted = $user->revokeAccess($article); + + $this->assertEquals(2, $deleted); + $this->assertFalse($user->hasAccess($article)); + } + + public function test_revoke_access_by_source_method(): void + { + $user = User::factory()->create(); + $a1 = Article::create(['title' => 'X']); + $a2 = Article::create(['title' => 'Y']); + $sub = Subscription::create(['name' => 'm']); + + $user->grantAccess($a1); // lifetime — survives + $user->grantAccess($a1, null, now()->addDays(30), $sub); + $user->grantAccess($a2, null, now()->addDays(30), $sub); + + $deleted = $user->revokeAccessBySource($sub); + + $this->assertEquals(2, $deleted); + $this->assertTrue($user->hasAccess($a1)); // via lifetime + $this->assertFalse($user->hasAccess($a2)); + } + + // ─── revokeAllAccess source filter ─────────────────────────── + + public function test_revoke_all_access_filtered_by_source(): void + { + $user = User::factory()->create(); + $a1 = Article::create(['title' => 'Filt1']); + $a2 = Article::create(['title' => 'Filt2']); + $sub = Subscription::create(['name' => 'm']); + + $user->grantAccess($a1); + $user->grantAccess($a1, null, now()->addDays(30), $sub); + $user->grantAccess($a2, null, now()->addDays(30), $sub); + + $deleted = $user->revokeAllAccess(Article::class, $sub); + + $this->assertEquals(2, $deleted); + $this->assertTrue($user->hasAccess($a1)); // lifetime survives + $this->assertFalse($user->hasAccess($a2)); + } + + // ─── syncAccess source scoping ─────────────────────────────── + + public function test_sync_access_scoped_to_source_does_not_touch_lifetime(): void + { + $user = User::factory()->create(); + $a1 = Article::create(['title' => 'L1']); + $a2 = Article::create(['title' => 'S1']); + $a3 = Article::create(['title' => 'S2']); + $sub = Subscription::create(['name' => 'm']); + + $user->grantAccess($a1); // lifetime + $user->grantAccess($a2, null, now()->addDays(30), $sub); // initial sub grant + + // Subscription's bundle changes from [a2] to [a3] + $user->syncAccess(Article::class, [$a3->id], null, now()->addDays(30), $sub); + + $this->assertTrue($user->hasAccess($a1), 'lifetime untouched'); + $this->assertFalse($user->hasAccess($a2), 'old sub grant removed'); + $this->assertTrue($user->hasAccess($a3), 'new sub grant added'); + } + + public function test_sync_access_without_source_only_touches_null_source_rows(): void + { + $user = User::factory()->create(); + $a1 = Article::create(['title' => 'M1']); + $a2 = Article::create(['title' => 'M2']); + $sub = Subscription::create(['name' => 'm']); + + $user->grantAccess($a1); // null source + $user->grantAccess($a2, null, now()->addDays(30), $sub); // sub source + + $user->syncAccess(Article::class, []); // wipe null-source grants + + $this->assertFalse($user->hasAccess($a1)); + $this->assertTrue($user->hasAccess($a2), 'sub-source grant must be untouched'); + } + + // ─── scope helpers on Access ───────────────────────────────── + + public function test_from_source_scope_filters_correctly(): void + { + $user = User::factory()->create(); + $article = Article::create(['title' => 'Scope']); + $sub = Subscription::create(['name' => 'm']); + + $user->grantAccess($article); + $user->grantAccess($article, null, now()->addDays(30), $sub); + + $rows = Access::query()->fromSource($sub)->get(); + + $this->assertCount(1, $rows); + $this->assertEquals($sub->id, $rows->first()->source_id); + } +} diff --git a/tests/Unit/HasAccessTest.php b/tests/Unit/HasAccessTest.php index 9f07220..fd0da7c 100644 --- a/tests/Unit/HasAccessTest.php +++ b/tests/Unit/HasAccessTest.php @@ -30,6 +30,9 @@ class HasAccessTest extends TestCase 'database' => ':memory:', 'prefix' => '', ]); + // Tests use workbench-specific UUID-aware migrations; disable the + // package's auto-load so the same tables aren't created twice. + $app['config']->set('roles.run_migrations', false); } protected function defineDatabaseMigrations(): void @@ -228,6 +231,7 @@ class HasAccessTest extends TestCase // Manually insert expired role membership DB::table(config('roles.table_names.role_member'))->insert([ + 'id' => (string) \Illuminate\Support\Str::uuid(), 'role_id' => $role->id, 'member_id' => $user->id, 'member_type' => $user->getMorphClass(), diff --git a/tests/Unit/HasPermissionsTest.php b/tests/Unit/HasPermissionsTest.php index d3b5254..ea91da1 100644 --- a/tests/Unit/HasPermissionsTest.php +++ b/tests/Unit/HasPermissionsTest.php @@ -27,6 +27,7 @@ class HasPermissionsTest extends TestCase 'database' => ':memory:', 'prefix' => '', ]); + $app['config']->set('roles.run_migrations', false); } protected function defineDatabaseMigrations(): void diff --git a/tests/Unit/HasRolesTest.php b/tests/Unit/HasRolesTest.php index 6cbb3e4..3180a91 100644 --- a/tests/Unit/HasRolesTest.php +++ b/tests/Unit/HasRolesTest.php @@ -27,6 +27,7 @@ class HasRolesTest extends TestCase 'database' => ':memory:', 'prefix' => '', ]); + $app['config']->set('roles.run_migrations', false); } protected function defineDatabaseMigrations(): void diff --git a/tests/Unit/MigrationAutoLoadTest.php b/tests/Unit/MigrationAutoLoadTest.php new file mode 100644 index 0000000..0943660 --- /dev/null +++ b/tests/Unit/MigrationAutoLoadTest.php @@ -0,0 +1,104 @@ +set('database.default', 'testing'); + $app['config']->set('database.connections.testing', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + } + + public function test_auto_load_creates_all_tables_on_migrate(): void + { + config()->set('roles.run_migrations', true); + + $this->artisan('migrate')->assertExitCode(0); + + $this->assertTrue(Schema::hasTable('roles')); + $this->assertTrue(Schema::hasTable('role_members')); + $this->assertTrue(Schema::hasTable('permissions')); + $this->assertTrue(Schema::hasTable('permission_members')); + $this->assertTrue(Schema::hasTable('permission_usages')); + $this->assertTrue(Schema::hasTable('accesses')); + } + + public function test_auto_load_includes_source_columns_on_accesses(): void + { + config()->set('roles.run_migrations', true); + + $this->artisan('migrate')->assertExitCode(0); + + $this->assertTrue(Schema::hasColumn('accesses', 'source_type')); + $this->assertTrue(Schema::hasColumn('accesses', 'source_id')); + } + + /** + * Production upgrade simulation: project already has the legacy access + * table (no source columns) from a previously published create migration. + * The new package version is composer-updated and `php artisan migrate` + * runs in the deploy pipeline. The auto-load create migrations must + * no-op (idempotent) and the add_source migration must add the columns. + */ + public function test_upgrade_with_existing_tables_runs_cleanly(): void + { + config()->set('roles.run_migrations', true); + + // Pre-create the old-style accesses table (no source columns) and + // the role tables, simulating a project that ran a previously + // published copy of these migrations. + \Illuminate\Support\Facades\Schema::create('permissions', function ($t) { + $t->id(); + $t->string('slug')->unique(); + $t->timestamps(); + }); + \Illuminate\Support\Facades\Schema::create('roles', function ($t) { + $t->id(); + $t->string('slug')->unique(); + $t->timestamps(); + }); + \Illuminate\Support\Facades\Schema::create('accesses', function ($t) { + $t->id(); + $t->morphs('entity'); + $t->morphs('accessible'); + $t->json('context')->nullable(); + $t->timestamp('expires_at')->nullable(); + $t->timestamps(); + $t->unique(['entity_type', 'entity_id', 'accessible_type', 'accessible_id'], 'access_unique'); + }); + + // The deploy pipeline's `php artisan migrate --force` step: + $this->artisan('migrate', ['--force' => true])->assertExitCode(0); + + // Old-style accesses table now has source columns added. + $this->assertTrue(Schema::hasColumn('accesses', 'source_id')); + $this->assertTrue(Schema::hasColumn('accesses', 'source_type')); + } + + // Note: the disabled-flag case (run_migrations = false) is exercised by + // every other test in this suite — they all set the flag false in + // defineEnvironment and rely on workbench migrations instead. If the + // package were auto-loading despite the flag, those tests would error on + // "table already exists" because workbench creates the same tables. +}