feat: add HasRequiredAccess trait for OR-combined entity dependencies
Introduces a generic "Required Access" mechanism: any model using HasRequiredAccess can list other entities as required-access targets; if the requesting entity has access to ANY of them — direct, role, or permission — the holder is considered unlocked. Sits alongside Required Roles / Permissions and is OR-combined with them. The unlock check resolves in a single EXISTS query that joins required_accesses with accesses, so cost stays O(1) regardless of target count. 20 new unit tests cover relations, sync semantics, expiry handling, isolation between holders, and the constant-cost query property. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
20d94caa33
commit
2ca17ba914
|
|
@ -26,6 +26,7 @@ return [
|
|||
'permission_usage' => \Blax\Roles\Models\PermissionUsage::class,
|
||||
'permission_member' => \Blax\Roles\Models\PermissionMember::class,
|
||||
'access' => \Blax\Roles\Models\Access::class,
|
||||
'required_access' => \Blax\Roles\Models\RequiredAccess::class,
|
||||
],
|
||||
|
||||
'table_names' => [
|
||||
|
|
@ -36,6 +37,7 @@ return [
|
|||
'role_member' => 'role_members',
|
||||
'role_permission' => 'role_permissions',
|
||||
'accesses' => 'accesses',
|
||||
'required_accesses' => 'required_accesses',
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
namespace Blax\Roles\Migrations;
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Required-access pivot.
|
||||
*
|
||||
* Each row says: to unlock the holder (e.g. a Lection), the entity
|
||||
* must already have access to the linked target (e.g. a Course). A holder
|
||||
* may list any number of targets — they are evaluated as an OR set, in the
|
||||
* same spirit as Required Roles / Required Permissions.
|
||||
*
|
||||
* Idempotent so it's safe to auto-run on a project that already has the
|
||||
* table from a previously published copy.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$table = config('roles.table_names.required_accesses', 'required_accesses');
|
||||
|
||||
if (Schema::hasTable($table)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::create($table, function (Blueprint $blueprint) {
|
||||
$blueprint->id();
|
||||
$blueprint->morphs('holder'); // The gated entity (e.g. Lection)
|
||||
$blueprint->morphs('required'); // The entity whose access unlocks the holder (e.g. Course)
|
||||
$blueprint->timestamps();
|
||||
|
||||
$blueprint->unique(
|
||||
['holder_type', 'holder_id', 'required_type', 'required_id'],
|
||||
'required_access_unique',
|
||||
);
|
||||
$blueprint->index(['required_type', 'required_id'], 'required_access_reverse');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists(config('roles.table_names.required_accesses', 'required_accesses'));
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
namespace Blax\Roles\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Pivot row for the "Required Access" feature.
|
||||
*
|
||||
* A holder model (anything using HasRequiredAccess) lists one or more
|
||||
* required targets. At access-check time, if the requesting entity has
|
||||
* an active Access entry to ANY of those targets, the holder is unlocked.
|
||||
* This sits alongside Required Roles / Required Permissions and is
|
||||
* evaluated with OR semantics.
|
||||
*/
|
||||
class RequiredAccess extends Model
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
protected $fillable = [
|
||||
'holder_id',
|
||||
'holder_type',
|
||||
'required_id',
|
||||
'required_type',
|
||||
];
|
||||
|
||||
public function __construct(array $attributes = [])
|
||||
{
|
||||
parent::__construct($attributes);
|
||||
|
||||
$this->table = config('roles.table_names.required_accesses') ?: parent::getTable();
|
||||
}
|
||||
|
||||
/**
|
||||
* The gated entity that owns this requirement.
|
||||
*/
|
||||
public function holder()
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
/**
|
||||
* The entity whose access unlocks the holder.
|
||||
*/
|
||||
public function required()
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
}
|
||||
|
|
@ -83,5 +83,6 @@ class RolesServiceProvider extends \Illuminate\Support\ServiceProvider
|
|||
$this->app->bind(\Blax\Roles\Models\PermissionUsage::class, fn($app) => $app->make($app->config['roles.models.permission_usage']));
|
||||
$this->app->bind(\Blax\Roles\Models\PermissionMember::class, fn($app) => $app->make($app->config['roles.models.permission_member']));
|
||||
$this->app->bind(\Blax\Roles\Models\Access::class, fn($app) => $app->make($app->config['roles.models.access']));
|
||||
$this->app->bind(\Blax\Roles\Models\RequiredAccess::class, fn($app) => $app->make($app->config['roles.models.required_access']));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -322,8 +322,11 @@ trait HasAccess
|
|||
/**
|
||||
* Get role IDs for resolving access through roles.
|
||||
* Returns null if this model doesn't use roles.
|
||||
*
|
||||
* Public so other access-resolution code (e.g. HasRequiredAccess) can
|
||||
* reuse the same logic when building cross-table queries.
|
||||
*/
|
||||
protected function resolveAccessRoleIds(): ?Collection
|
||||
public function resolveAccessRoleIds(): ?Collection
|
||||
{
|
||||
if (! method_exists($this, 'roles')) {
|
||||
return null;
|
||||
|
|
@ -344,8 +347,11 @@ trait HasAccess
|
|||
/**
|
||||
* Get permission IDs for resolving access through permissions.
|
||||
* Returns null if this model doesn't use permissions.
|
||||
*
|
||||
* Public so other access-resolution code (e.g. HasRequiredAccess) can
|
||||
* reuse the same logic when building cross-table queries.
|
||||
*/
|
||||
protected function resolveAccessPermissionIds(): ?Collection
|
||||
public function resolveAccessPermissionIds(): ?Collection
|
||||
{
|
||||
if (! method_exists($this, 'permissions')) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,198 @@
|
|||
<?php
|
||||
|
||||
namespace Blax\Roles\Traits;
|
||||
|
||||
use Blax\Roles\Models\RequiredAccess;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* "Required Access" — declarative dependency between gated entities.
|
||||
*
|
||||
* A holder lists one or more required targets. At check-time, if the
|
||||
* requesting entity (typically a User) has any active Access entry to
|
||||
* ANY of those targets — direct, role-based, or permission-based — the
|
||||
* holder is considered unlocked.
|
||||
*
|
||||
* This sits alongside Required Roles and Required Permissions and is
|
||||
* evaluated with OR semantics: holder is unlocked if any of {required
|
||||
* roles, required permissions, required access} resolves true.
|
||||
*
|
||||
* Performance: hasUnlockedRequiredAccessFor() resolves the question in a
|
||||
* single SQL statement that joins required_accesses with accesses, instead
|
||||
* of N+1 lookups across linked targets.
|
||||
*/
|
||||
trait HasRequiredAccess
|
||||
{
|
||||
/**
|
||||
* Pivot rows declaring this entity's required-access targets.
|
||||
*/
|
||||
public function requiredAccessLinks()
|
||||
{
|
||||
return $this->morphMany(
|
||||
config('roles.models.required_access', RequiredAccess::class),
|
||||
'holder',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the actual target Models (loaded polymorphically).
|
||||
*
|
||||
* Convenience for UIs that need to render the linked entities. For the
|
||||
* lock check, prefer hasUnlockedRequiredAccessFor() — it never loads
|
||||
* targets and resolves in a single SQL statement.
|
||||
*/
|
||||
public function requiredAccessTargets(): EloquentCollection
|
||||
{
|
||||
$links = $this->requiredAccessLinks()->with('required')->get();
|
||||
|
||||
return $links->map(fn(Model $link) => $link->required)
|
||||
->filter()
|
||||
->values()
|
||||
->pipe(fn($items) => new EloquentCollection($items->all()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a required-access target. Idempotent on (holder, required).
|
||||
*/
|
||||
public function addRequiredAccess(Model $target): Model
|
||||
{
|
||||
$modelClass = config('roles.models.required_access', RequiredAccess::class);
|
||||
|
||||
return $modelClass::firstOrCreate([
|
||||
'holder_type' => $this->getMorphClass(),
|
||||
'holder_id' => $this->getKey(),
|
||||
'required_type' => $target->getMorphClass(),
|
||||
'required_id' => $target->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a required-access target.
|
||||
*
|
||||
* @return int Number of pivot rows deleted (0 or 1).
|
||||
*/
|
||||
public function removeRequiredAccess(Model $target): int
|
||||
{
|
||||
return $this->requiredAccessLinks()
|
||||
->where('required_type', $target->getMorphClass())
|
||||
->where('required_id', $target->getKey())
|
||||
->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace this entity's required-access set with the given targets.
|
||||
* Adds missing links, removes ones not in the new set.
|
||||
*
|
||||
* @param iterable<Model> $targets
|
||||
*/
|
||||
public function syncRequiredAccess(iterable $targets): void
|
||||
{
|
||||
$modelClass = config('roles.models.required_access', RequiredAccess::class);
|
||||
$holderType = $this->getMorphClass();
|
||||
$holderId = $this->getKey();
|
||||
|
||||
// Build the desired set keyed by "type|id" for cheap diffing.
|
||||
$desired = [];
|
||||
foreach ($targets as $target) {
|
||||
$key = $target->getMorphClass() . '|' . $target->getKey();
|
||||
$desired[$key] = [
|
||||
'type' => $target->getMorphClass(),
|
||||
'id' => $target->getKey(),
|
||||
];
|
||||
}
|
||||
|
||||
$existing = $this->requiredAccessLinks()->get();
|
||||
$existingKeys = [];
|
||||
foreach ($existing as $link) {
|
||||
$key = $link->required_type . '|' . $link->required_id;
|
||||
$existingKeys[$key] = $link;
|
||||
}
|
||||
|
||||
// Delete the ones not in desired set.
|
||||
foreach ($existingKeys as $key => $link) {
|
||||
if (! array_key_exists($key, $desired)) {
|
||||
$link->delete();
|
||||
}
|
||||
}
|
||||
|
||||
// Insert the ones not yet present.
|
||||
foreach ($desired as $key => $entry) {
|
||||
if (! array_key_exists($key, $existingKeys)) {
|
||||
$modelClass::create([
|
||||
'holder_type' => $holderType,
|
||||
'holder_id' => $holderId,
|
||||
'required_type' => $entry['type'],
|
||||
'required_id' => $entry['id'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Does $entity have access to ANY of this holder's required-access
|
||||
* targets? Direct, role-based, and permission-based access entries
|
||||
* all count, mirroring HasAccess::hasAccess().
|
||||
*
|
||||
* Resolves the question in a single SQL statement (no per-target
|
||||
* round-trips). Returns false if the entity is null, has no
|
||||
* required-access links, or none of them are unlocked.
|
||||
*/
|
||||
public function hasUnlockedRequiredAccessFor(?Model $entity): bool
|
||||
{
|
||||
if (! $entity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$accessTable = (new (config('roles.models.access')))->getTable();
|
||||
$requiredTable = config('roles.table_names.required_accesses', 'required_accesses');
|
||||
|
||||
$query = DB::table($requiredTable)
|
||||
->where($requiredTable . '.holder_type', $this->getMorphClass())
|
||||
->where($requiredTable . '.holder_id', $this->getKey())
|
||||
->whereExists(function ($sub) use ($entity, $accessTable, $requiredTable) {
|
||||
$sub->select(DB::raw(1))
|
||||
->from($accessTable)
|
||||
->whereColumn($accessTable . '.accessible_type', $requiredTable . '.required_type')
|
||||
->whereColumn($accessTable . '.accessible_id', $requiredTable . '.required_id')
|
||||
->where(function ($q) use ($accessTable) {
|
||||
$q->whereNull($accessTable . '.expires_at')
|
||||
->orWhere($accessTable . '.expires_at', '>', now());
|
||||
})
|
||||
->where(function ($q) use ($entity, $accessTable) {
|
||||
// 1. Direct access from the entity itself.
|
||||
$q->where(function ($s) use ($entity, $accessTable) {
|
||||
$s->where($accessTable . '.entity_type', $entity->getMorphClass())
|
||||
->where($accessTable . '.entity_id', $entity->getKey());
|
||||
});
|
||||
|
||||
// 2. Access conferred by any of the entity's roles.
|
||||
if (method_exists($entity, 'resolveAccessRoleIds')) {
|
||||
$roleIds = $entity->resolveAccessRoleIds();
|
||||
if ($roleIds !== null && $roleIds->isNotEmpty()) {
|
||||
$roleMorphClass = (new (config('roles.models.role')))->getMorphClass();
|
||||
$q->orWhere(function ($s) use ($accessTable, $roleMorphClass, $roleIds) {
|
||||
$s->where($accessTable . '.entity_type', $roleMorphClass)
|
||||
->whereIn($accessTable . '.entity_id', $roleIds);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Access conferred by any of the entity's permissions.
|
||||
if (method_exists($entity, 'resolveAccessPermissionIds')) {
|
||||
$permissionIds = $entity->resolveAccessPermissionIds();
|
||||
if ($permissionIds !== null && $permissionIds->isNotEmpty()) {
|
||||
$permMorphClass = (new (config('roles.models.permission')))->getMorphClass();
|
||||
$q->orWhere(function ($s) use ($accessTable, $permMorphClass, $permissionIds) {
|
||||
$s->where($accessTable . '.entity_type', $permMorphClass)
|
||||
->whereIn($accessTable . '.entity_id', $permissionIds);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return $query->exists();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,340 @@
|
|||
<?php
|
||||
|
||||
namespace Blax\Roles\Tests\Unit;
|
||||
|
||||
use Blax\Roles\Models\Permission;
|
||||
use Blax\Roles\Models\RequiredAccess;
|
||||
use Blax\Roles\Models\Role;
|
||||
use Blax\Roles\RolesServiceProvider;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Orchestra\Testbench\TestCase;
|
||||
use Workbench\App\Models\Article;
|
||||
use Workbench\App\Models\User;
|
||||
|
||||
class HasRequiredAccessTest 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' => '',
|
||||
]);
|
||||
$app['config']->set('roles.run_migrations', false);
|
||||
}
|
||||
|
||||
protected function defineDatabaseMigrations(): void
|
||||
{
|
||||
$this->loadMigrationsFrom(__DIR__ . '/../../workbench/database/migrations');
|
||||
}
|
||||
|
||||
// ─── relations / mutations ─────────────────────────────────────────
|
||||
|
||||
public function test_required_access_links_returns_empty_by_default(): void
|
||||
{
|
||||
$article = Article::create(['title' => 'Holder']);
|
||||
$this->assertCount(0, $article->requiredAccessLinks);
|
||||
}
|
||||
|
||||
public function test_add_required_access_creates_pivot_row(): void
|
||||
{
|
||||
$holder = Article::create(['title' => 'Holder']);
|
||||
$target = Article::create(['title' => 'Target']);
|
||||
|
||||
$link = $holder->addRequiredAccess($target);
|
||||
|
||||
$this->assertInstanceOf(RequiredAccess::class, $link);
|
||||
$this->assertSame($holder->getMorphClass(), $link->holder_type);
|
||||
$this->assertEquals($holder->id, $link->holder_id);
|
||||
$this->assertSame($target->getMorphClass(), $link->required_type);
|
||||
$this->assertEquals($target->id, $link->required_id);
|
||||
}
|
||||
|
||||
public function test_add_required_access_is_idempotent(): void
|
||||
{
|
||||
$holder = Article::create(['title' => 'Holder']);
|
||||
$target = Article::create(['title' => 'Target']);
|
||||
|
||||
$first = $holder->addRequiredAccess($target);
|
||||
$second = $holder->addRequiredAccess($target);
|
||||
|
||||
$this->assertSame($first->id, $second->id);
|
||||
$this->assertCount(1, $holder->requiredAccessLinks()->get());
|
||||
}
|
||||
|
||||
public function test_remove_required_access_deletes_pivot_row(): void
|
||||
{
|
||||
$holder = Article::create(['title' => 'Holder']);
|
||||
$target = Article::create(['title' => 'Target']);
|
||||
|
||||
$holder->addRequiredAccess($target);
|
||||
$deleted = $holder->removeRequiredAccess($target);
|
||||
|
||||
$this->assertSame(1, $deleted);
|
||||
$this->assertCount(0, $holder->requiredAccessLinks()->get());
|
||||
}
|
||||
|
||||
public function test_remove_required_access_returns_zero_when_not_linked(): void
|
||||
{
|
||||
$holder = Article::create(['title' => 'Holder']);
|
||||
$other = Article::create(['title' => 'Other']);
|
||||
|
||||
$this->assertSame(0, $holder->removeRequiredAccess($other));
|
||||
}
|
||||
|
||||
public function test_sync_required_access_replaces_existing_set(): void
|
||||
{
|
||||
$holder = Article::create(['title' => 'Holder']);
|
||||
$a = Article::create(['title' => 'A']);
|
||||
$b = Article::create(['title' => 'B']);
|
||||
$c = Article::create(['title' => 'C']);
|
||||
|
||||
$holder->addRequiredAccess($a);
|
||||
$holder->addRequiredAccess($b);
|
||||
|
||||
$holder->syncRequiredAccess([$b, $c]);
|
||||
|
||||
$links = $holder->requiredAccessLinks()->get();
|
||||
// Morph columns store IDs as strings (uuidMorphs); compare as strings.
|
||||
$ids = $links->pluck('required_id')->map(fn($id) => (string) $id)->all();
|
||||
$expected = [(string) $b->id, (string) $c->id];
|
||||
|
||||
$this->assertEqualsCanonicalizing($expected, $ids);
|
||||
$this->assertCount(2, $links);
|
||||
}
|
||||
|
||||
public function test_sync_required_access_with_empty_clears_all(): void
|
||||
{
|
||||
$holder = Article::create(['title' => 'Holder']);
|
||||
$a = Article::create(['title' => 'A']);
|
||||
$holder->addRequiredAccess($a);
|
||||
|
||||
$holder->syncRequiredAccess([]);
|
||||
|
||||
$this->assertCount(0, $holder->requiredAccessLinks()->get());
|
||||
}
|
||||
|
||||
public function test_required_access_targets_resolves_polymorphic_models(): void
|
||||
{
|
||||
$holder = Article::create(['title' => 'Holder']);
|
||||
$a = Article::create(['title' => 'A']);
|
||||
$b = Article::create(['title' => 'B']);
|
||||
|
||||
$holder->addRequiredAccess($a);
|
||||
$holder->addRequiredAccess($b);
|
||||
|
||||
$targets = $holder->requiredAccessTargets();
|
||||
|
||||
$this->assertCount(2, $targets);
|
||||
$this->assertEqualsCanonicalizing(
|
||||
[$a->id, $b->id],
|
||||
$targets->pluck('id')->all(),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── hasUnlockedRequiredAccessFor — the access check ─────────────
|
||||
|
||||
public function test_returns_false_when_entity_is_null(): void
|
||||
{
|
||||
$holder = Article::create(['title' => 'Holder']);
|
||||
$target = Article::create(['title' => 'Target']);
|
||||
$holder->addRequiredAccess($target);
|
||||
|
||||
$this->assertFalse($holder->hasUnlockedRequiredAccessFor(null));
|
||||
}
|
||||
|
||||
public function test_returns_false_when_no_required_access_links_exist(): void
|
||||
{
|
||||
$holder = Article::create(['title' => 'Holder']);
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->assertFalse($holder->hasUnlockedRequiredAccessFor($user));
|
||||
}
|
||||
|
||||
public function test_returns_false_when_user_has_no_access_to_any_target(): void
|
||||
{
|
||||
$holder = Article::create(['title' => 'Holder']);
|
||||
$target = Article::create(['title' => 'Target']);
|
||||
$holder->addRequiredAccess($target);
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->assertFalse($holder->hasUnlockedRequiredAccessFor($user));
|
||||
}
|
||||
|
||||
public function test_returns_true_when_user_has_direct_access_to_target(): void
|
||||
{
|
||||
$holder = Article::create(['title' => 'Holder']);
|
||||
$target = Article::create(['title' => 'Target']);
|
||||
$holder->addRequiredAccess($target);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$user->grantAccess($target);
|
||||
|
||||
$this->assertTrue($holder->hasUnlockedRequiredAccessFor($user));
|
||||
}
|
||||
|
||||
public function test_returns_true_when_user_has_role_based_access_to_target(): void
|
||||
{
|
||||
$holder = Article::create(['title' => 'Holder']);
|
||||
$target = Article::create(['title' => 'Target']);
|
||||
$holder->addRequiredAccess($target);
|
||||
|
||||
$role = Role::create(['slug' => 'premium']);
|
||||
$role->grantAccess($target); // role -> target
|
||||
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole($role);
|
||||
|
||||
$this->assertTrue($holder->hasUnlockedRequiredAccessFor($user));
|
||||
}
|
||||
|
||||
public function test_returns_true_when_user_has_permission_based_access_to_target(): void
|
||||
{
|
||||
$holder = Article::create(['title' => 'Holder']);
|
||||
$target = Article::create(['title' => 'Target']);
|
||||
$holder->addRequiredAccess($target);
|
||||
|
||||
$permission = Permission::create(['slug' => 'view-target']);
|
||||
$permission->grantAccess($target); // permission -> target
|
||||
|
||||
$user = User::factory()->create();
|
||||
$user->assignPermission($permission);
|
||||
|
||||
$this->assertTrue($holder->hasUnlockedRequiredAccessFor($user));
|
||||
}
|
||||
|
||||
public function test_returns_true_when_only_one_of_multiple_targets_is_unlocked(): void
|
||||
{
|
||||
$holder = Article::create(['title' => 'Holder']);
|
||||
$a = Article::create(['title' => 'A']);
|
||||
$b = Article::create(['title' => 'B']);
|
||||
$c = Article::create(['title' => 'C']);
|
||||
$holder->syncRequiredAccess([$a, $b, $c]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$user->grantAccess($b); // only b granted
|
||||
|
||||
$this->assertTrue($holder->hasUnlockedRequiredAccessFor($user));
|
||||
}
|
||||
|
||||
public function test_returns_false_when_access_to_target_is_expired(): void
|
||||
{
|
||||
$holder = Article::create(['title' => 'Holder']);
|
||||
$target = Article::create(['title' => 'Target']);
|
||||
$holder->addRequiredAccess($target);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$user->grantAccess($target, expiresAt: Carbon::now()->subDay());
|
||||
|
||||
$this->assertFalse($holder->hasUnlockedRequiredAccessFor($user));
|
||||
}
|
||||
|
||||
public function test_returns_true_when_access_expires_in_the_future(): void
|
||||
{
|
||||
$holder = Article::create(['title' => 'Holder']);
|
||||
$target = Article::create(['title' => 'Target']);
|
||||
$holder->addRequiredAccess($target);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$user->grantAccess($target, expiresAt: Carbon::now()->addDay());
|
||||
|
||||
$this->assertTrue($holder->hasUnlockedRequiredAccessFor($user));
|
||||
}
|
||||
|
||||
public function test_does_not_unlock_holder_via_unrelated_access_grants(): void
|
||||
{
|
||||
$holder = Article::create(['title' => 'Holder']);
|
||||
$required = Article::create(['title' => 'Required']);
|
||||
$unrelated = Article::create(['title' => 'Unrelated']);
|
||||
$holder->addRequiredAccess($required);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$user->grantAccess($unrelated); // not the required one
|
||||
|
||||
$this->assertFalse($holder->hasUnlockedRequiredAccessFor($user));
|
||||
}
|
||||
|
||||
public function test_other_holders_are_not_unlocked_by_a_user_with_one_unlock(): void
|
||||
{
|
||||
$holderA = Article::create(['title' => 'Holder A']);
|
||||
$holderB = Article::create(['title' => 'Holder B']);
|
||||
$targetA = Article::create(['title' => 'Target A']);
|
||||
$targetB = Article::create(['title' => 'Target B']);
|
||||
|
||||
$holderA->addRequiredAccess($targetA);
|
||||
$holderB->addRequiredAccess($targetB);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$user->grantAccess($targetA);
|
||||
|
||||
$this->assertTrue($holderA->hasUnlockedRequiredAccessFor($user));
|
||||
$this->assertFalse($holderB->hasUnlockedRequiredAccessFor($user));
|
||||
}
|
||||
|
||||
// ─── performance ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The whole point of the SQL fast-path is that adding more
|
||||
* required-access targets does not multiply the work. Resolving the
|
||||
* user's role/permission space has a fixed prelude cost (a few
|
||||
* lookups), and the lock decision itself is one EXISTS query — that
|
||||
* shape must stay constant regardless of target count.
|
||||
*/
|
||||
public function test_unlock_check_query_count_does_not_scale_with_target_count(): void
|
||||
{
|
||||
$role = Role::create(['slug' => 'premium']);
|
||||
|
||||
$smallHolder = Article::create(['title' => 'Small']);
|
||||
$smallTargets = collect(range(1, 3))->map(fn($i) => Article::create(['title' => "S{$i}"]));
|
||||
$smallHolder->syncRequiredAccess($smallTargets);
|
||||
$role->grantAccess($smallTargets->first());
|
||||
|
||||
$largeHolder = Article::create(['title' => 'Large']);
|
||||
$largeTargets = collect(range(1, 50))->map(fn($i) => Article::create(['title' => "L{$i}"]));
|
||||
$largeHolder->syncRequiredAccess($largeTargets);
|
||||
$role->grantAccess($largeTargets->first());
|
||||
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole($role);
|
||||
|
||||
$countQueries = function ($holder) use ($user) {
|
||||
$holder = $holder->fresh();
|
||||
$u = $user->fresh();
|
||||
DB::flushQueryLog();
|
||||
DB::enableQueryLog();
|
||||
$unlocked = $holder->hasUnlockedRequiredAccessFor($u);
|
||||
$queries = DB::getQueryLog();
|
||||
DB::disableQueryLog();
|
||||
$this->assertTrue($unlocked, 'expected ' . $holder->title . ' unlocked');
|
||||
return count($queries);
|
||||
};
|
||||
|
||||
$smallCount = $countQueries($smallHolder);
|
||||
$largeCount = $countQueries($largeHolder);
|
||||
|
||||
$this->assertSame(
|
||||
$smallCount,
|
||||
$largeCount,
|
||||
"Query count grew from {$smallCount} (3 targets) to {$largeCount} (50 targets) "
|
||||
. '— hasUnlockedRequiredAccessFor must stay O(1) in target count.',
|
||||
);
|
||||
|
||||
// Sanity bound: prelude + EXISTS check is small and fixed.
|
||||
$this->assertLessThanOrEqual(
|
||||
6,
|
||||
$largeCount,
|
||||
'unexpectedly many queries (' . $largeCount . ') for unlock check',
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue