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.
This commit is contained in:
parent
01cff931bc
commit
20d94caa33
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
<?php
|
||||
|
||||
namespace Blax\Roles\Migrations;
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* Idempotent: each Schema::create is gated by Schema::hasTable so running
|
||||
* after composer-update on a project that already has the tables (via a
|
||||
* previously published copy of this migration) is a no-op rather than a
|
||||
* failure. This keeps `php artisan migrate --force` safe to run as part
|
||||
* of an existing deploy pipeline.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable(config('roles.table_names.permissions'))) {
|
||||
Schema::create(config('roles.table_names.permissions'), function (Blueprint $table) {
|
||||
$table->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'));
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
namespace Blax\Roles\Migrations;
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* Idempotent so it's safe to auto-run on a project that already has the
|
||||
* accesses table (created by a previously published copy). Source columns
|
||||
* are added via the separate add_source migration on existing installs.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
$table = config('roles.table_names.accesses', 'accesses');
|
||||
|
||||
if (Schema::hasTable($table)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::create($table, function (Blueprint $blueprint) {
|
||||
$blueprint->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'));
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
<?php
|
||||
|
||||
namespace Blax\Roles\Migrations;
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* Upgrade-path migration: adds `source_type`/`source_id` morph columns to
|
||||
* the existing accesses table so grants can be tied to a conferring
|
||||
* source model (Subscription, Order, etc.) and cleaned up when that
|
||||
* source is removed.
|
||||
*
|
||||
* Also drops the old single-row uniqueness constraint, since a single
|
||||
* (entity, accessible) pair can now have multiple rows distinguished by
|
||||
* source. Idempotency is enforced at the code level by `grantAccess`.
|
||||
*
|
||||
* Idempotent: runs cleanly on installs that already have source columns
|
||||
* (e.g. a fresh install where the create migration already added them
|
||||
* via the auto-load order). Safe to re-run.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
$table = config('roles.table_names.accesses', 'accesses');
|
||||
|
||||
// Drop the old uniqueness in its own statement so a missing index
|
||||
// (fresh install or already-dropped) doesn't abort the rest. Each
|
||||
// Blueprint flush is its own transaction-ish unit.
|
||||
try {
|
||||
Schema::table($table, function (Blueprint $blueprint) {
|
||||
$blueprint->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
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Blax\Roles\Migrations;
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create(config('roles.table_names.accesses', 'accesses'), function (Blueprint $table) {
|
||||
$table->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'));
|
||||
}
|
||||
};
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Blax\Roles\Migrations;
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Permission
|
||||
Schema::create(config('roles.table_names.permissions'), function (Blueprint $table) {
|
||||
$table->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'));
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace Blax\Roles\Events;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
|
||||
/**
|
||||
* Fired after a cascade cleanup of access rows tied to a source model
|
||||
* (e.g. when a subscription is canceled and all its grants are revoked).
|
||||
*
|
||||
* Listen to this if you need to log, audit, or notify when grants are
|
||||
* removed in bulk because their source disappeared.
|
||||
*/
|
||||
class SourceAccessesRevoked
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(
|
||||
public Model $source,
|
||||
public int $count,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -2,17 +2,21 @@
|
|||
|
||||
namespace Blax\Roles\Models;
|
||||
|
||||
use Blax\Roles\Events\SourceAccessesRevoked;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Access extends Model
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
protected $fillable = [
|
||||
'entity_id',
|
||||
'entity_type',
|
||||
'accessible_id',
|
||||
'accessible_type',
|
||||
'source_id',
|
||||
'source_type',
|
||||
'context',
|
||||
'expires_at',
|
||||
];
|
||||
|
|
@ -45,6 +49,15 @@ class Access extends Model
|
|||
return $this->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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
// 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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
$this->publishes($publishMap, 'roles-migrations');
|
||||
}
|
||||
|
||||
protected function registerModelBindings(): void
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
namespace Blax\Roles\Traits;
|
||||
|
||||
/**
|
||||
* Apply this trait to any model that can be the source of an access grant
|
||||
* (Subscription, Order, RoleMember, etc.) and all access rows referencing it
|
||||
* via the polymorphic `source` columns will be deleted automatically when
|
||||
* the source model is deleted.
|
||||
*
|
||||
* If the model uses Laravel's SoftDeletes you'll get cleanup on hard delete
|
||||
* by default. Override `revokesAccessOnSoftDelete()` to also clean up on
|
||||
* soft delete.
|
||||
*
|
||||
* Prefer this trait when you want the cleanup wired up implicitly. If you'd
|
||||
* rather wire it through a Laravel observer or listen for a domain event,
|
||||
* use `Access::revokeBySource($model)` directly — same effect.
|
||||
*/
|
||||
trait RevokesAccessOnDelete
|
||||
{
|
||||
public static function bootRevokesAccessOnDelete(): void
|
||||
{
|
||||
static::deleted(function ($model) {
|
||||
if (
|
||||
method_exists($model, 'isForceDeleting')
|
||||
&& ! $model->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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,347 @@
|
|||
<?php
|
||||
|
||||
namespace Blax\Roles\Tests\Unit;
|
||||
|
||||
use Blax\Roles\Events\SourceAccessesRevoked;
|
||||
use Blax\Roles\Models\Access;
|
||||
use Blax\Roles\RolesServiceProvider;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Orchestra\Testbench\TestCase;
|
||||
use Workbench\App\Models\Article;
|
||||
use Workbench\App\Models\Subscription;
|
||||
use Workbench\App\Models\User;
|
||||
|
||||
/**
|
||||
* Coverage for source-tied grants: subscription cascade cleanup, lifetime +
|
||||
* subscription coexistence, manual grants surviving cascades, sync scoping,
|
||||
* and renewal idempotency.
|
||||
*/
|
||||
class HasAccessSourceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function getPackageProviders($app): array
|
||||
{
|
||||
return [RolesServiceProvider::class];
|
||||
}
|
||||
|
||||
protected function defineEnvironment($app): void
|
||||
{
|
||||
$app['config']->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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ class HasPermissionsTest extends TestCase
|
|||
'database' => ':memory:',
|
||||
'prefix' => '',
|
||||
]);
|
||||
$app['config']->set('roles.run_migrations', false);
|
||||
}
|
||||
|
||||
protected function defineDatabaseMigrations(): void
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ class HasRolesTest extends TestCase
|
|||
'database' => ':memory:',
|
||||
'prefix' => '',
|
||||
]);
|
||||
$app['config']->set('roles.run_migrations', false);
|
||||
}
|
||||
|
||||
protected function defineDatabaseMigrations(): void
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
<?php
|
||||
|
||||
namespace Blax\Roles\Tests\Unit;
|
||||
|
||||
use Blax\Roles\RolesServiceProvider;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Orchestra\Testbench\TestCase;
|
||||
|
||||
/**
|
||||
* Verifies the plug-and-play migration story:
|
||||
* - With `roles.run_migrations = true` (default), the package's own
|
||||
* migrations auto-run on `migrate`, creating every expected table.
|
||||
* - With the flag flipped to false, no tables are created — the host project
|
||||
* is in charge of publishing + running them.
|
||||
*/
|
||||
class MigrationAutoLoadTest extends TestCase
|
||||
{
|
||||
protected function getPackageProviders($app): array
|
||||
{
|
||||
return [RolesServiceProvider::class];
|
||||
}
|
||||
|
||||
protected function defineEnvironment($app): void
|
||||
{
|
||||
$app['config']->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.
|
||||
}
|
||||
Loading…
Reference in New Issue